From 416f3181ccf704339c955714d8b65f553b1c1098 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:52:02 +1000 Subject: [PATCH 01/69] Add a UEVR mod manager: install, play in VR and remove from Frame Control Installed Unreal Engine games get a "VR mod" button. ui/frame_mods.py runs on the Frame: it downloads the pinned official UEVR 1.05 release and Microsoft's .NET 6 runtimes (hash-checked, safely unpacked) into the game's Proton prefix with a receipt; starts the game, runs the injector in the game's own Wine session and presses OpenVR + Inject with XSendEvent (gamescope keeps real input on the game); confirms UEVRBackend.dll is loaded and SteamVR counts frames; and removes exactly what it added. Verified on a real Frame 2026-09-29 with Gravitas, through the UI: install, four injections (three from a cold start), remove back to the pre-install prefix listing. The headset was unworn, so the VR image, tracking and controls are not verified. Part of #26. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- docs/evidence/mods-2026-09-29.md | 78 ++++++ docs/how-the-frame-works.md | 1 + docs/img/uevr-dialog.png | Bin 0 -> 34646 bytes docs/mods.md | 89 ++++++- docs/steam-games.md | 6 +- tests/test_mods.py | 270 +++++++++++++++++++ ui/frame_mods.py | 437 +++++++++++++++++++++++++++++++ ui/index.html | 62 ++++- ui/server.py | 25 +- 10 files changed, 943 insertions(+), 27 deletions(-) create mode 100644 docs/evidence/mods-2026-09-29.md create mode 100644 docs/img/uevr-dialog.png create mode 100644 tests/test_mods.py create mode 100644 ui/frame_mods.py diff --git a/README.md b/README.md index cc952d0..eb425ad 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ Frame's software fits together, all checked against a real headset and labelled | [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection | | [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste | | [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 | -| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet | +| [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 | | [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 | diff --git a/docs/evidence/mods-2026-09-29.md b/docs/evidence/mods-2026-09-29.md new file mode 100644 index 0000000..0015ec1 --- /dev/null +++ b/docs/evidence/mods-2026-09-29.md @@ -0,0 +1,78 @@ +# 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 and 11:33–11:49. 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//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; `start` also retries up to three times | +| 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 | + +## 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 11:49. diff --git a/docs/how-the-frame-works.md b/docs/how-the-frame-works.md index 5c392d1..863b527 100644 --- a/docs/how-the-frame-works.md +++ b/docs/how-the-frame-works.md @@ -47,6 +47,7 @@ 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/` 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//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 ` 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 ` 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 | diff --git a/docs/img/uevr-dialog.png b/docs/img/uevr-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..42849222ea671aab47b33cdeaf780e33f5e4e8ed GIT binary patch literal 34646 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 literal 0 HcmV?d00001 diff --git a/docs/mods.md b/docs/mods.md index 27a7c2a..c25aed6 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -1,11 +1,14 @@ # Flat-to-VR mods and Beat Saber songs -**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. +**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. The mod manager must be Frame Control's own implementation. Mods and songs are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other @@ -15,27 +18,82 @@ unofficial mod mirrors are part of this work. ## Per-game support -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 +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 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 | 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. | +| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Through the app's UI: install, then three cold **Play in VR** runs (54–58 s each) all loaded `UEVRBackend.dll` into the game, and 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. | | 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**, 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. | +| 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. | | 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 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. +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 + +![The VR mod dialog while UEVR runs in Gravitas](img/uevr-dialog.png) + +**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 Microsoft's Windows x64 .NET 6.0.36 and Windows Desktop runtimes (the + injector is a .NET 6 WPF app, and Proton ships no .NET). Each file is + checked against its pinned SHA-256 (UEVR) or SHA-512 (.NET, from Microsoft's + release metadata) before use. Archives are unpacked into a staging folder, + rejecting entries that escape it, symlinks and oversized contents, and + then moved into `/drive_c/frame-control/`. 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. +- **Play in VR.** Launches the game through Steam if it isn't running and + waits for its window. It then starts `UEVRInjector.exe` with the game's own + Proton, prefix and Wine settings, read from the game process, so both are in + one Wine session. It presses **OpenVR** and **Inject**, confirms 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. + +Things found on the way (verified 2026-09-29): + +- Under gamescope, the full-screen game keeps the pointer and focus, and + XTest clicks, raising or refocusing the injector window don't change that. + So in the headset you couldn't click the injector yourself, and Frame + Control sends X `ButtonPress`/`ButtonRelease` events straight to the + injector's window instead. The positions are for UEVR 1.05's layout; the + window first maps at 960 px wide and settles at 625 px. `start` refuses to + click if it doesn't settle at that width. +- The injector's `--attach=` didn't inject under Wine. It also + didn't restore the OpenVR choice it had saved, so Frame Control chooses + OpenVR every time. +- With only the game's variables (no `HOME`), the injector hit an + `AccessViolationException` in .NET at startup. With the normal environment + underneath, all four later starts worked. `start` also retries a + crashed injector up to three times. +- 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. ## Beat Saber: songs first @@ -62,7 +120,10 @@ or dependency for Frame Control. ## Requirements for our manager -These are **planned**, not implemented or verified: +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: 1. Resolve the selected Steam game's real library, installed build, executable architecture and Proton prefix. Confirm ownership through Steam; a directory diff --git a/docs/steam-games.md b/docs/steam-games.md index 068a153..1e48ee1 100644 --- a/docs/steam-games.md +++ b/docs/steam-games.md @@ -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. -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. +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. ## How it works diff --git a/tests/test_mods.py b/tests/test_mods.py new file mode 100644 index 0000000..b4b5e41 --- /dev/null +++ b/tests/test_mods.py @@ -0,0 +1,270 @@ +"""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() + + +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"})} + for url, _ in frame_mods.DOTNET["files"]: + self.downloads[url] = zip_bytes({"shared/Microsoft.NETCore.App/6.0.36/x.dll": b"MZ"}) + self.pin(self.downloads) + + def pin(self, downloads): + """Serve these bytes and pin their real hashes in place of the published ones.""" + uevr = dict(frame_mods.UEVR, sha256=hashlib.sha256(downloads[frame_mods.UEVR["url"]]).hexdigest()) + dotnet = dict(frame_mods.DOTNET, files=[(u, hashlib.sha512(downloads[u]).hexdigest()) + for u, _ in frame_mods.DOTNET["files"]]) + for name, value in (("UEVR", uevr), ("DOTNET", dotnet)): + p = mock.patch.object(frame_mods, name, value) + 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/UEVRInjector.exe").is_file()) + self.assertTrue((self.managed() / "dotnet-6.0.36/shared/Microsoft.NETCore.App/6.0.36/x.dll").is_file()) + 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, "outside"): + 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") + + +class Downloads(FakeLibrary): + def test_hash_mismatch_deletes_and_installs_nothing(self): + self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRInjector.exe": 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({"UEVRInjector.exe": b"MZ", "../../evil.dll": b"MZ"}), + zip_bytes({"UEVRInjector.exe": 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) + + +class Start(FakeLibrary): + def test_needs_install_first(self): + with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"): + frame_mods.start(APPID) + + def test_refuses_to_click_an_unexpected_layout(self): + frame_mods.install(APPID) + self.running = 4242 + env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0" + windows = [[(1, "SkyArk", 1280)], [(1, "SkyArk", 1280)], [(1, "SkyArk", 1280), (2, "UEVR", 700)]] + with mock.patch("builtins.open", mock.mock_open(read_data=env)), \ + mock.patch.object(frame_mods, "loaded", return_value=False), \ + mock.patch.object(frame_mods, "x11_windows", side_effect=lambda d: windows.pop(0) if len(windows) > 1 else windows[0]), \ + mock.patch.object(frame_mods.subprocess, "Popen") as popen, \ + mock.patch.object(frame_mods, "x_click") as click, \ + mock.patch.object(frame_mods.os, "killpg") as kill, \ + mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)), \ + mock.patch.object(frame_mods.time, "sleep"): + popen.return_value.poll.return_value = None # the injector stays up + with self.assertRaisesRegex(frame_mods.Fail, "700 px"): + frame_mods.start(APPID) + click.assert_not_called() + kill.assert_called_once_with(popen.return_value.pid, frame_mods.signal.SIGKILL) + env_used = popen.call_args.kwargs["env"] + self.assertNotIn("WINESERVERSOCKET", env_used, "an inherited fd number means nothing to a new process") + self.assertEqual(env_used["DOTNET_ROOT"], "C:\\frame-control\\dotnet-6.0.36") + self.assertEqual(env_used["WINEPREFIX"], "/x") + self.assertIn("HOME", env_used, "fontconfig needs a HOME for its cache") + + def test_injector_that_keeps_crashing_is_reported(self): + frame_mods.install(APPID) + self.running = 4242 + with mock.patch("builtins.open", mock.mock_open(read_data="WINEPREFIX=/x\0DISPLAY=:1\0")), \ + mock.patch.object(frame_mods, "loaded", return_value=False), \ + mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \ + mock.patch.object(frame_mods.subprocess, "Popen") as popen, \ + mock.patch.object(frame_mods, "x_click") as click, \ + mock.patch.object(frame_mods.time, "sleep"): + popen.return_value.poll.return_value = 134 # died before showing its window + with self.assertRaisesRegex(frame_mods.Fail, "crashed on start 3 times"): + frame_mods.start(APPID) + self.assertEqual(popen.call_count, 3) + click.assert_not_called() + + +class Helper(unittest.TestCase): + 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) diff --git a/ui/frame_mods.py b/ui/frame_mods.py new file mode 100644 index 0000000..f4db415 --- /dev/null +++ b/ui/frame_mods.py @@ -0,0 +1,437 @@ +#!/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): UEVR 1.05's injector runs in +the game's own Wine session, and after OpenVR + Inject the game loads +UEVRBackend.dll and SteamVR counts frame submits for it. Stereo image, head +tracking and controls were not seen: the headset was unworn (docs/mods.md). + +Nothing here is bundled. UEVR comes from praydog's GitHub release, and the +Windows .NET 6 runtime it needs from Microsoft's release metadata URLs, each +checked against a pinned hash. Files live in /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 signal +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" +VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd" +MANAGED = "frame-control" # drive_c/ 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"} +# UEVRInjector.exe is a .NET 6 WPF app; Proton ships no .NET. These are the +# win-x64 ZIPs listed in builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json. +DOTNET = {"version": "6.0.36", "dir": "dotnet-6.0.36", "files": [ + ("https://builds.dotnet.microsoft.com/dotnet/Runtime/6.0.36/dotnet-runtime-6.0.36-win-x64.zip", + "935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2"), + ("https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/6.0.36/windowsdesktop-runtime-6.0.36-win-x64.zip", + "cee88fef07643dceb3d34ea71b64eeae85b8e39e7042bc4d35bc3a1f1df29373681dc48e31c01c9f593ec412699f6762b6fc5817433283c89df35a27ce8885bf")]} +MAX_UNPACKED = 600 << 20 # the three archives unpack to about 190 MB + +# UEVR 1.05's injector window (625 px wide), in window coordinates. Under +# gamescope the full-screen game keeps the pointer and focus, so real clicks +# can't reach the injector; XSendEvent to its window does. It doesn't restore +# its saved runtime choice under Wine, and --attach didn't inject, so choose +# OpenVR (SteamVR's own API) and press Inject every time. +INJECTOR_WIDTH = 625 +CLICK_OPENVR, CLICK_INJECT = (125, 137), (365, 113) +# The game's environment the injector 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)=") + + +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: + shutil.copyfileobj(r, f, 1 << 20) + except OSError 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 += [(fetch(url, digest, "sha512"), DOTNET["dir"]) for url, digest in DOTNET["files"]] + 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) + if not (stage / UEVR["dir"] / "UEVRInjector.exe").is_file(): + raise Fail("UEVR.zip has no UEVRInjector.exe") + for sub in (UEVR["dir"], DOTNET["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 / DOTNET["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"]] + [u for u, _ in DOTNET["files"]]} + 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 x_click(display, window, points): + """Send ButtonPress/Release straight to a window (gamescope blocks real input to it).""" + import ctypes as C + x11 = C.CDLL("libX11.so.6") + x11.XOpenDisplay.restype = C.c_void_p + + class Button(C.Structure): + _fields_ = [("type", C.c_int), ("serial", C.c_ulong), ("send_event", C.c_int), ("display", C.c_void_p), + ("window", C.c_ulong), ("root", C.c_ulong), ("subwindow", C.c_ulong), ("time", C.c_ulong), + ("x", C.c_int), ("y", C.c_int), ("x_root", C.c_int), ("y_root", C.c_int), + ("state", C.c_uint), ("button", C.c_uint), ("same_screen", C.c_int)] + + class Event(C.Union): + _fields_ = [("xbutton", Button), ("pad", C.c_long * 24)] + + d = x11.XOpenDisplay(display.encode()) + if not d: + raise Fail(f"can't open X display {display}") + d = C.c_void_p(d) + root = x11.XDefaultRootWindow(d) + ox, oy, child = C.c_int(), C.c_int(), C.c_ulong() + x11.XTranslateCoordinates(d, C.c_ulong(window), C.c_ulong(root), 0, 0, C.byref(ox), C.byref(oy), C.byref(child)) + for x, y in points: + for kind, mask, state in ((4, 1 << 2, 0), (5, 1 << 3, 1 << 8)): # press, then release with Button1 held + e = Event() + b = e.xbutton + b.type, b.send_event, b.window, b.root, b.x, b.y = kind, 1, window, root, x, y + b.x_root, b.y_root, b.state, b.button, b.same_screen = x + ox.value, y + oy.value, state, 1, 1 + x11.XSendEvent(d, C.c_ulong(window), 1, C.c_long(mask), C.byref(e)) + x11.XFlush(d) + time.sleep(0.15) + time.sleep(1.5) + x11.XCloseDisplay(d) + + +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 + return next((s.get("frame_submits") for s in stats if 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: + 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)) + env = dict(line.split("=", 1) for line in open(f"/proc/{pid}/environ").read().split("\0") + if GAME_ENV.match(line)) + display = env.get("DISPLAY") + if not display or "WINEPREFIX" not in env: + raise Fail(f"{exe} doesn't look like a Proton game process") + # The game needs its D3D11 window before UEVR can hook it. Proton's own + # helper windows ("Steam", "Default IME", …) are small. + wait("the game window", 90, lambda: any(w[2] >= 640 and w[1] != "UEVR" for w in x11_windows(display))) + time.sleep(10) + root = f"C:\\{MANAGED}\\{DOTNET['dir']}" + # Our own environment (HOME and the rest) under the game's Wine settings: + # with only the game's variables, fontconfig had no cache directory. + env = {**os.environ, **env, "DOTNET_ROOT": root, "DOTNET_ROOT_X64": root} + # The .NET injector sometimes dies at startup under Wine and FEX (an + # AccessViolationException, 2026-09-29), so give it a few tries. + for attempt in range(3): + before = {w[0] for w in x11_windows(display) if w[1] == "UEVR"} + log = open(CACHE / f"injector-{appid}.log", "w") if CACHE.is_dir() else subprocess.DEVNULL + injector = subprocess.Popen(["wine", f"C:\\{MANAGED}\\{UEVR['dir']}\\UEVRInjector.exe"], env=env, + stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True, + cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED / UEVR["dir"])) + try: + win = wait("the UEVR injector window", 90, + lambda: injector.poll() is not None or next( + (w for w in x11_windows(display) if w[1] == "UEVR" and w[0] not in before), None)) + except Fail: + os.killpg(injector.pid, signal.SIGKILL) + raise + if win is not True: + break + else: + raise Fail(f"the UEVR injector crashed on start {attempt + 1} times; " + f"its log is in ~/.cache/frame-control/mods/injector-{appid}.log") + try: + # It maps at a default size, then WPF lays it out (seen: 960 px, then 625). + try: + wait("the UEVR window's layout", 30, + lambda: any(w[0] == win[0] and w[2] == INJECTOR_WIDTH for w in x11_windows(display)), step=1) + except Fail: + width = next((w[2] for w in x11_windows(display) if w[0] == win[0]), "?") + raise Fail(f"the UEVR window is {width} px wide, not {INJECTOR_WIDTH}; " + "its layout changed, so not clicking blind") from None + time.sleep(5) # let it fill its process list + x_click(display, win[0], [CLICK_OPENVR, CLICK_INJECT]) + try: + wait("UEVRBackend.dll to load in the game", 45, lambda: loaded(pid, "UEVRBackend.dll")) + except Fail: + raise Fail(f"UEVR didn't inject into {exe}. Its log is in ~/.cache/frame-control/mods/injector-{appid}.log") from None + except Fail: + os.killpg(injector.pid, signal.SIGKILL) # don't leave an injector window behind a failed start + raise + 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 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"]) + targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values()) + for t in targets: # a receipt only ever points inside this game's prefix + real = os.path.realpath(t) + if not real.startswith(prefix + os.sep): + raise Fail(f"receipt lists a path outside the game's prefix: {t}") + for t in targets: + shutil.rmtree(t, ignore_errors=True) + for t in r.get("remove_if_empty", []): + if os.path.realpath(t).startswith(prefix + os.sep): + 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") + return {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]](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) diff --git a/ui/index.html b/ui/index.html index 8ac1265..3cacd4e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -254,10 +254,10 @@ .and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; } .and-col { display: grid; gap: 22px; align-content: start; } .rep-item .s { white-space: normal; } - #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; + #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); } - #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); } - #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } + #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); } + #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #modDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } #repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; } #repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; } #titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; @@ -666,6 +666,20 @@
+ +

Play in VR with UEVR

+

+

UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads + the official UEVR 1.05 release and Microsoft's .NET 6 runtime it needs (about 70 MB), checks their hashes, + and keeps them in this game's Proton prefix, not the game's folder. Remove takes them out again.

+

Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies; + UEVR's own menu in the headset has the per-game settings.

+
+ + + +
+

Report an APK

@@ -1701,7 +1715,8 @@ function gameButtons(id, name, free) { const open = ``; if (d && d.appid === id) return `${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%`; if (g && g.installed) return `Installed` + - ``; + `` + + ``; if (g) return ``; return `${free ? "Get" : "Buy"} on Steam ↗${open}`; } @@ -1773,6 +1788,45 @@ document.body.addEventListener("click", async e => { }); loadOwned(); +// ---- VR mod (UEVR) for one installed game; frame_mods.py does the work ---- +const mod = { appid: null, name: "" }; +function showMod(st, note) { + const busy = note === null; + $("modTitle").textContent = `${mod.name} in VR`; + $("modState").textContent = !st ? "Checking the game on the Frame…" + : !st.eligible ? "This doesn't look like an Unreal Engine game, so UEVR can't run it." + : !st.prefix ? "Play the game once first, so Steam sets up its Proton prefix." + : st.injected ? "UEVR is running in the game now. Put on the headset." + : st.installed ? `UEVR ${st.version} is installed for this game.${st.running ? " The game is running; Play in VR adds UEVR to it." : ""}` + : `UEVR ${st.version} isn't installed for this game yet.`; + $("modMsg").textContent = busy ? "Working… this can take a few minutes" : note || ""; + const ok = st && st.eligible && st.prefix; + $("modInstall").hidden = !ok || st.installed; $("modStart").hidden = !ok || !st.installed || st.injected; + $("modRemove").hidden = !st || !st.installed; + for (const b of ["modInstall", "modStart", "modRemove"]) $(b).disabled = busy || (b === "modRemove" && st && st.running); +} +async function modAction(action, label) { + const appid = mod.appid; + showMod(mod.st, null); + const res = await act(`${label} for ${mod.name}`, () => api("/api/mods", { action, appid })); + if (appid !== mod.appid) return; + try { mod.st = await api("/api/mods", { action: "status", appid }); } catch {} + showMod(mod.st, res ? res.message : "Didn't work; the log has the reason"); +} +document.body.addEventListener("click", async e => { + const b = e.target.closest("[data-mod]"); if (!b) return; + mod.appid = b.dataset.mod; mod.name = b.dataset.name; mod.st = null; + showMod(null, ""); + $("modDlg").showModal(); + const appid = mod.appid; + try { const st = await api("/api/mods", { action: "status", appid }); if (appid === mod.appid) { mod.st = st; showMod(st, ""); } } + catch (err) { if (appid === mod.appid) $("modState").textContent = err.message; } +}); +$("modInstall").onclick = () => modAction("install", "Install UEVR"); +$("modStart").onclick = () => modAction("start", "Start UEVR"); +$("modRemove").onclick = () => modAction("uninstall", "Remove UEVR"); +$("modClose").onclick = () => $("modDlg").close(); + // ---- Android apps (own Lepton instance each) + catalogue ---- const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might work", unlikely: "Probably crashes", no: "Won't work" }; const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] }; diff --git a/ui/server.py b/ui/server.py index dd86ff6..052ac31 100755 --- a/ui/server.py +++ b/ui/server.py @@ -407,13 +407,14 @@ def launch(body): return {"message": f"Launching {appid}"} -def steam_frame(*args, timeout=40): - """Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON.""" +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.""" try: out = ssh("python3 - " + " ".join(map(shlex.quote, args)), - stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout) + stdin=(HERE / script).read_text(), timeout=timeout) except Failure as e: - # frame_steam.py prints {"error": ...} on stdout when it fails, but ssh() + # The helper 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: @@ -434,6 +435,20 @@ def steam(body): return steam_frame(action, appid) +# Downloads are about 70 MB; starting waits for the game, the injector and SteamVR. +MOD_TIMEOUT = {"status": 40, "install": 600, "start": 360, "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() @@ -1227,7 +1242,7 @@ def _sweep_one(prefix, d): pass -POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, +POST = {"/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/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} From e3176a2de8999c3bfaf1dec1cfdcbd04bcbc4f9c Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:53:28 +1000 Subject: [PATCH 02/69] UEVR: inject with Frame Control's own script instead of UEVR's GUI injector UEVR's .NET injector, driven with XSendEvent, injected in only 6 of 8 cold starts on the Frame: in bad sessions every new injector ignored input or never showed a window. start() now runs frame_inject.py under python.org's embeddable Python (pinned SHA-256) in the game's Wine session, repeating the frontend's own Inject steps: nullify VR plugins, load openvr_api.dll, set Frontend_RequestedRuntime, load UEVRBackend.dll. Verified 9/9 cold starts in 33-35 s and a clean UI install/play/remove cycle. No .NET download any more (47 MB installed instead of about 190 MB). Also from the SWE-2 Max review: one mod action at a time (flock), a download size cap, errors always reported as JSON, a receipt can't name another app's prefix, 0 frames counts as an answer, and a longer server timeout for start. Part of #26. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/evidence/mods-2026-09-29.md | 37 +++- docs/img/uevr-dialog.png | Bin 34646 -> 33395 bytes docs/mods.md | 67 ++++--- tests/test_mods.py | 135 +++++++++----- ui/frame_mods.py | 303 ++++++++++++++++++------------- ui/index.html | 5 +- ui/server.py | 2 +- 7 files changed, 348 insertions(+), 201 deletions(-) diff --git a/docs/evidence/mods-2026-09-29.md b/docs/evidence/mods-2026-09-29.md index 0015ec1..b94c8b8 100644 --- a/docs/evidence/mods-2026-09-29.md +++ b/docs/evidence/mods-2026-09-29.md @@ -4,7 +4,8 @@ [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 and 11:33–11:49. Battery 45 % → +`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40 and +12:44–12:52. Battery 45 % → 93 %, on charge throughout. **Not observed:** the VR image, head tracking and controller input. SteamVR @@ -69,10 +70,42 @@ page's **VR mod** dialog, driven with a headless Chromium: | 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. + ## 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 11:49. +- The test lock was released at 12:52. diff --git a/docs/img/uevr-dialog.png b/docs/img/uevr-dialog.png index 42849222ea671aab47b33cdeaf780e33f5e4e8ed..18876d717156cbb5e53308ff9ed40c38ecc936a2 100644 GIT binary patch literal 33395 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%0Ah4M@c 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 literal 34646 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 diff --git a/docs/mods.md b/docs/mods.md index c25aed6..d6e7f91 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -26,7 +26,7 @@ means an upstream source describes it; “inferred” means it still needs a tes | 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.** Through the app's UI: install, then three cold **Play in VR** runs (54–58 s each) all loaded `UEVRBackend.dll` into the game, and 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, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Nine cold **Play in VR** runs (33–35 s each), then the full cycle again 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. | | 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. | @@ -56,41 +56,46 @@ like `frame_steam.py`. 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 Microsoft's Windows x64 .NET 6.0.36 and Windows Desktop runtimes (the - injector is a .NET 6 WPF app, and Proton ships no .NET). Each file is - checked against its pinned SHA-256 (UEVR) or SHA-512 (.NET, from Microsoft's - release metadata) before use. Archives are unpacked into a staging folder, - rejecting entries that escape it, symlinks and oversized contents, and - then moved into `/drive_c/frame-control/`. Nothing goes into the + 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 + `/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. + 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 starts `UEVRInjector.exe` with the game's own - Proton, prefix and Wine settings, read from the game process, so both are in - one Wine session. It presses **OpenVR** and **Inject**, confirms that - `UEVRBackend.dll` is mapped into the game, and reads SteamVR's frame count - for the app (`vrcmd --stats`). + 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. -Things found on the way (verified 2026-09-29): +Why not UEVR's own injector (verified 2026-09-29): -- Under gamescope, the full-screen game keeps the pointer and focus, and - XTest clicks, raising or refocusing the injector window don't change that. - So in the headset you couldn't click the injector yourself, and Frame - Control sends X `ButtonPress`/`ButtonRelease` events straight to the - injector's window instead. The positions are for UEVR 1.05's layout; the - window first maps at 960 px wide and settles at 625 px. `start` refuses to - click if it doesn't settle at that width. -- The injector's `--attach=` didn't inject under Wine. It also - didn't restore the OpenVR choice it had saved, so Frame Control chooses - OpenVR every time. -- With only the game's variables (no `HOME`), the injector hit an - `AccessViolationException` in .NET at startup. With the normal environment - underneath, all four later starts worked. `start` also retries a - crashed injector up to three times. +- 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=` 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 9 of 9 cold starts, in 33–35 s. - 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. @@ -152,7 +157,11 @@ 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 test runtimes. + 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`. - [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) diff --git a/tests/test_mods.py b/tests/test_mods.py index b4b5e41..b0ad78f 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -64,17 +64,15 @@ class FakeLibrary(unittest.TestCase): 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"})} - for url, _ in frame_mods.DOTNET["files"]: - self.downloads[url] = zip_bytes({"shared/Microsoft.NETCore.App/6.0.36/x.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.""" - uevr = dict(frame_mods.UEVR, sha256=hashlib.sha256(downloads[frame_mods.UEVR["url"]]).hexdigest()) - dotnet = dict(frame_mods.DOTNET, files=[(u, hashlib.sha512(downloads[u]).hexdigest()) - for u, _ in frame_mods.DOTNET["files"]]) - for name, value in (("UEVR", uevr), ("DOTNET", dotnet)): - p = mock.patch.object(frame_mods, name, value) + 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", @@ -109,8 +107,9 @@ class InstallUninstall(FakeLibrary): 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/UEVRInjector.exe").is_file()) - self.assertTrue((self.managed() / "dotnet-6.0.36/shared/Microsoft.NETCore.App/6.0.36/x.dll").is_file()) + 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" @@ -166,9 +165,19 @@ class InstallUninstall(FakeLibrary): self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad") + 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({"UEVRInjector.exe": b"tampered"}) + 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()) @@ -176,8 +185,8 @@ class Downloads(FakeLibrary): self.assertIsNone(frame_mods.read_receipt(APPID)) def test_archive_escaping_its_folder_is_refused(self): - for bad in (zip_bytes({"UEVRInjector.exe": b"MZ", "../../evil.dll": b"MZ"}), - zip_bytes({"UEVRInjector.exe": b"MZ"}, links=["openvr_api.dll"])): + 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): @@ -193,52 +202,88 @@ class Downloads(FakeLibrary): 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): + lock = Path(tempfile.mkdtemp()) / "mods.lock" + with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held: + frame_mods.fcntl.flock(held, frame_mods.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//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 test_refuses_to_click_an_unexpected_layout(self): + 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" - windows = [[(1, "SkyArk", 1280)], [(1, "SkyArk", 1280)], [(1, "SkyArk", 1280), (2, "UEVR", 700)]] - with mock.patch("builtins.open", mock.mock_open(read_data=env)), \ - mock.patch.object(frame_mods, "loaded", return_value=False), \ - mock.patch.object(frame_mods, "x11_windows", side_effect=lambda d: windows.pop(0) if len(windows) > 1 else windows[0]), \ - mock.patch.object(frame_mods.subprocess, "Popen") as popen, \ - mock.patch.object(frame_mods, "x_click") as click, \ - mock.patch.object(frame_mods.os, "killpg") as kill, \ - mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)), \ - mock.patch.object(frame_mods.time, "sleep"): - popen.return_value.poll.return_value = None # the injector stays up - with self.assertRaisesRegex(frame_mods.Fail, "700 px"): - frame_mods.start(APPID) - click.assert_not_called() - kill.assert_called_once_with(popen.return_value.pid, frame_mods.signal.SIGKILL) - env_used = popen.call_args.kwargs["env"] - self.assertNotIn("WINESERVERSOCKET", env_used, "an inherited fd number means nothing to a new process") - self.assertEqual(env_used["DOTNET_ROOT"], "C:\\frame-control\\dotnet-6.0.36") - self.assertEqual(env_used["WINEPREFIX"], "/x") - self.assertIn("HOME", env_used, "fontconfig needs a HOME for its cache") - - def test_injector_that_keeps_crashing_is_reported(self): - frame_mods.install(APPID) - self.running = 4242 - with mock.patch("builtins.open", mock.mock_open(read_data="WINEPREFIX=/x\0DISPLAY=:1\0")), \ - mock.patch.object(frame_mods, "loaded", return_value=False), \ + 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, "Popen") as popen, \ - mock.patch.object(frame_mods, "x_click") as click, \ + 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"): - popen.return_value.poll.return_value = 134 # died before showing its window - with self.assertRaisesRegex(frame_mods.Fail, "crashed on start 3 times"): - frame_mods.start(APPID) - self.assertEqual(popen.call_count, 3) - click.assert_not_called() + 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): + 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], diff --git a/ui/frame_mods.py b/ui/frame_mods.py index f4db415..1aebbf7 100644 --- a/ui/frame_mods.py +++ b/ui/frame_mods.py @@ -8,22 +8,26 @@ Usage: python3 - status APPID # can UEVR run here, is it installed, is the g 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): UEVR 1.05's injector runs in -the game's own Wine session, and after OpenVR + Inject the game loads -UEVRBackend.dll and SteamVR counts frame submits for it. Stereo image, head -tracking and controls were not seen: the headset was unworn (docs/mods.md). +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). -Nothing here is bundled. UEVR comes from praydog's GitHub release, and the -Windows .NET 6 runtime it needs from Microsoft's release metadata URLs, each -checked against a pinned hash. Files live in /drive_c/frame-control, -never in the game's own folder, and a receipt records what to remove. +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 +/drive_c/frame-control, never in the game's own folder, and a receipt +records what to remove. """ +import fcntl import hashlib import json import os import re import shutil -import signal import subprocess import sys import time @@ -35,35 +39,122 @@ 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/ 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"} -# UEVRInjector.exe is a .NET 6 WPF app; Proton ships no .NET. These are the -# win-x64 ZIPs listed in builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json. -DOTNET = {"version": "6.0.36", "dir": "dotnet-6.0.36", "files": [ - ("https://builds.dotnet.microsoft.com/dotnet/Runtime/6.0.36/dotnet-runtime-6.0.36-win-x64.zip", - "935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2"), - ("https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/6.0.36/windowsdesktop-runtime-6.0.36-win-x64.zip", - "cee88fef07643dceb3d34ea71b64eeae85b8e39e7042bc4d35bc3a1f1df29373681dc48e31c01c9f593ec412699f6762b6fc5817433283c89df35a27ce8885bf")]} -MAX_UNPACKED = 600 << 20 # the three archives unpack to about 190 MB +# 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 -# UEVR 1.05's injector window (625 px wide), in window coordinates. Under -# gamescope the full-screen game keeps the pointer and focus, so real clicks -# can't reach the injector; XSendEvent to its window does. It doesn't restore -# its saved runtime choice under Wine, and --attach didn't inject, so choose -# OpenVR (SteamVR's own API) and press Inject every time. -INJECTOR_WIDTH = 625 -CLICK_OPENVR, CLICK_INJECT = (125, 137), (365, 113) -# The game's environment the injector needs to join its Wine session. Not +# 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) +k32.OpenProcess.restype = W.HANDLE +k32.VirtualAllocEx.restype = ctypes.c_void_p +k32.VirtualAllocEx.argtypes = [W.HANDLE, ctypes.c_void_p, ctypes.c_size_t, W.DWORD, W.DWORD] +k32.WriteProcessMemory.argtypes = [W.HANDLE, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p] +k32.CreateRemoteThread.restype = W.HANDLE +k32.CreateRemoteThread.argtypes = [W.HANDLE, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p, + W.DWORD, ctypes.c_void_p] +k32.GetModuleHandleW.restype = W.HMODULE +k32.GetProcAddress.restype = ctypes.c_void_p +k32.GetProcAddress.argtypes = [W.HMODULE, ctypes.c_char_p] +k32.LoadLibraryW.restype = W.HMODULE +k32.CreateToolhelp32Snapshot.restype = W.HANDLE + +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": msg})); sys.exit(1) + +def find_pid(): + snap = k32.CreateToolhelp32Snapshot(2, 0) # TH32CS_SNAPPROCESS + e = Entry(); e.size = ctypes.sizeof(e) + ok = k32.Process32FirstW(snap, ctypes.byref(e)) + while ok: + if e.name.lower() == exe.lower(): + k32.CloseHandle(snap); return e.pid + ok = k32.Process32NextW(snap, ctypes.byref(e)) + k32.CloseHandle(snap) + +def module_base(pid, path): + snap = k32.CreateToolhelp32Snapshot(0x18, pid) # TH32CS_SNAPMODULE | SNAPMODULE32 + m = Module(); m.size = ctypes.sizeof(m) + ok = k32.Module32FirstW(snap, ctypes.byref(m)) + while ok: + if m.path.lower() == path.lower(): + k32.CloseHandle(snap); return m.base + ok = k32.Module32NextW(snap, ctypes.byref(m)) + k32.CloseHandle(snap) + +def remote_call(proc, fn, arg=None, wait_ms=10000): + t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None) + if not t: + fail(f"CreateRemoteThread failed ({ctypes.get_last_error()})") + k32.WaitForSingleObject(t, wait_ms); 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 or not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None): + fail(f"couldn't write into {exe} ({ctypes.get_last_error()})") + remote_call(proc, k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW"), mem) + base = module_base(pid, path) + if not base: + fail(f"{name} didn't load into {exe}") + return path, base + +pid = find_pid() +if not pid: + fail(f"{exe} isn't running") +proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS +if not proc: + fail(f"couldn't open {exe} ({ctypes.get_last_error()})") +path, base = inject(proc, pid, "UEVRPluginNullifier.dll") +local = k32.LoadLibraryW(path) +offset = k32.GetProcAddress(local, b"nullify") - local # same DLL, same layout in both processes +remote_call(proc, base + offset, wait_ms=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") +print(json.dumps({"pid": pid})) +""" + + class Fail(Exception): pass @@ -173,8 +264,11 @@ def fetch(url, digest, algo): part = path.with_suffix(path.suffix + ".part") try: with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f: - shutil.copyfileobj(r, f, 1 << 20) - except OSError as e: + 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) @@ -217,7 +311,7 @@ def install(appid): 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 += [(fetch(url, digest, "sha512"), DOTNET["dir"]) for url, digest in DOTNET["files"]] + 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 @@ -227,9 +321,13 @@ def install(appid): budget = MAX_UNPACKED for archive, sub in archives: budget = unpack(archive, stage / sub, budget) - if not (stage / UEVR["dir"] / "UEVRInjector.exe").is_file(): - raise Fail("UEVR.zip has no UEVRInjector.exe") - for sub in (UEVR["dir"], DOTNET["dir"]): + 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: @@ -241,13 +339,13 @@ def install(appid): 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 / DOTNET["dir"])], + "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"]] + [u for u, _ in DOTNET["files"]]} + "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)) @@ -265,55 +363,22 @@ def x11_windows(display): 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 x_click(display, window, points): - """Send ButtonPress/Release straight to a window (gamescope blocks real input to it).""" - import ctypes as C - x11 = C.CDLL("libX11.so.6") - x11.XOpenDisplay.restype = C.c_void_p - - class Button(C.Structure): - _fields_ = [("type", C.c_int), ("serial", C.c_ulong), ("send_event", C.c_int), ("display", C.c_void_p), - ("window", C.c_ulong), ("root", C.c_ulong), ("subwindow", C.c_ulong), ("time", C.c_ulong), - ("x", C.c_int), ("y", C.c_int), ("x_root", C.c_int), ("y_root", C.c_int), - ("state", C.c_uint), ("button", C.c_uint), ("same_screen", C.c_int)] - - class Event(C.Union): - _fields_ = [("xbutton", Button), ("pad", C.c_long * 24)] - - d = x11.XOpenDisplay(display.encode()) - if not d: - raise Fail(f"can't open X display {display}") - d = C.c_void_p(d) - root = x11.XDefaultRootWindow(d) - ox, oy, child = C.c_int(), C.c_int(), C.c_ulong() - x11.XTranslateCoordinates(d, C.c_ulong(window), C.c_ulong(root), 0, 0, C.byref(ox), C.byref(oy), C.byref(child)) - for x, y in points: - for kind, mask, state in ((4, 1 << 2, 0), (5, 1 << 3, 1 << 8)): # press, then release with Button1 held - e = Event() - b = e.xbutton - b.type, b.send_event, b.window, b.root, b.x, b.y = kind, 1, window, root, x, y - b.x_root, b.y_root, b.state, b.button, b.same_screen = x + ox.value, y + oy.value, state, 1, 1 - x11.XSendEvent(d, C.c_ulong(window), 1, C.c_long(mask), C.byref(e)) - x11.XFlush(d) - time.sleep(0.15) - time.sleep(1.5) - x11.XCloseDisplay(d) - - 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 - return next((s.get("frame_submits") for s in stats if s.get("key") == f"steam.app.{appid}"), 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: + 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}") @@ -332,64 +397,43 @@ def start(appid): 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)) - env = dict(line.split("=", 1) for line in open(f"/proc/{pid}/environ").read().split("\0") - if GAME_ENV.match(line)) - display = env.get("DISPLAY") - if not display or "WINEPREFIX" not in env: - raise Fail(f"{exe} doesn't look like a Proton game process") - # The game needs its D3D11 window before UEVR can hook it. Proton's own - # helper windows ("Steam", "Default IME", …) are small. - wait("the game window", 90, lambda: any(w[2] >= 640 and w[1] != "UEVR" for w in x11_windows(display))) - time.sleep(10) - root = f"C:\\{MANAGED}\\{DOTNET['dir']}" - # Our own environment (HOME and the rest) under the game's Wine settings: - # with only the game's variables, fontconfig had no cache directory. - env = {**os.environ, **env, "DOTNET_ROOT": root, "DOTNET_ROOT_X64": root} - # The .NET injector sometimes dies at startup under Wine and FEX (an - # AccessViolationException, 2026-09-29), so give it a few tries. - for attempt in range(3): - before = {w[0] for w in x11_windows(display) if w[1] == "UEVR"} - log = open(CACHE / f"injector-{appid}.log", "w") if CACHE.is_dir() else subprocess.DEVNULL - injector = subprocess.Popen(["wine", f"C:\\{MANAGED}\\{UEVR['dir']}\\UEVRInjector.exe"], env=env, - stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True, - cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED / UEVR["dir"])) - try: - win = wait("the UEVR injector window", 90, - lambda: injector.poll() is not None or next( - (w for w in x11_windows(display) if w[1] == "UEVR" and w[0] not in before), None)) - except Fail: - os.killpg(injector.pid, signal.SIGKILL) - raise - if win is not True: - break - else: - raise Fail(f"the UEVR injector crashed on start {attempt + 1} times; " - f"its log is in ~/.cache/frame-control/mods/injector-{appid}.log") try: - # It maps at a default size, then WPF lays it out (seen: 960 px, then 625). - try: - wait("the UEVR window's layout", 30, - lambda: any(w[0] == win[0] and w[2] == INJECTOR_WIDTH for w in x11_windows(display)), step=1) - except Fail: - width = next((w[2] for w in x11_windows(display) if w[0] == win[0]), "?") - raise Fail(f"the UEVR window is {width} px wide, not {INJECTOR_WIDTH}; " - "its layout changed, so not clicking blind") from None - time.sleep(5) # let it fill its process list - x_click(display, win[0], [CLICK_OPENVR, CLICK_INJECT]) - try: - wait("UEVRBackend.dll to load in the game", 45, lambda: loaded(pid, "UEVRBackend.dll")) - except Fail: - raise Fail(f"UEVR didn't inject into {exe}. Its log is in ~/.cache/frame-control/mods/injector-{appid}.log") from None + 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 = next((json.loads(l) for l in reversed(out.stdout.splitlines()) if l.startswith("{")), 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: - os.killpg(injector.pid, signal.SIGKILL) # don't leave an injector window behind a failed start - raise + 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 else - "; SteamVR hasn't reported frames from it yet"), + 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)} @@ -400,6 +444,8 @@ def uninstall(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']}") targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values()) for t in targets: # a receipt only ever points inside this game's prefix real = os.path.realpath(t) @@ -426,7 +472,17 @@ def uninstall(appid): 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") - return {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]](int(argv[1])) + action = {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]] + if action is status: + return status(int(argv[1])) + # 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__": @@ -435,3 +491,6 @@ if __name__ == "__main__": 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) diff --git a/ui/index.html b/ui/index.html index 3cacd4e..4990255 100644 --- a/ui/index.html +++ b/ui/index.html @@ -670,8 +670,9 @@

Play in VR with UEVR

UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads - the official UEVR 1.05 release and Microsoft's .NET 6 runtime it needs (about 70 MB), checks their hashes, - and keeps them in this game's Proton prefix, not the game's folder. Remove takes them out again.

+ the official UEVR 1.05 release and python.org's Windows Python, which it uses to load UEVR into the game + (about 20 MB), checks their hashes, and keeps them in this game's Proton prefix, not the game's folder. + Remove takes them out again.

Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies; UEVR's own menu in the headset has the per-game settings.

diff --git a/ui/server.py b/ui/server.py index 052ac31..711d390 100755 --- a/ui/server.py +++ b/ui/server.py @@ -436,7 +436,7 @@ def steam(body): # Downloads are about 70 MB; starting waits for the game, the injector and SteamVR. -MOD_TIMEOUT = {"status": 40, "install": 600, "start": 360, "uninstall": 60} +MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60} def mods(body): From 2f056dbcffa3c0e6f86ccb3ad3cf1651b7ad91e1 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:04:12 +1000 Subject: [PATCH 03/69] UEVR: address the second review; recheck on the Frame frame_inject.py declares every Win32 signature, checks each result, frees its remote buffers and closes handles. Uninstall only accepts our folder and UEVR's own per-game settings folders, and keeps the receipt if anything can't be removed. The injector's reply is parsed defensively. Rechecked on a real Frame: install, three cold starts injected (33-35 s), clean uninstall. Part of #26. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/evidence/mods-2026-09-29.md | 19 ++++- docs/mods.md | 4 +- tests/test_mods.py | 21 ++++- ui/frame_mods.py | 140 +++++++++++++++++++------------ ui/server.py | 2 +- 5 files changed, 126 insertions(+), 60 deletions(-) diff --git a/docs/evidence/mods-2026-09-29.md b/docs/evidence/mods-2026-09-29.md index b94c8b8..605a6c2 100644 --- a/docs/evidence/mods-2026-09-29.md +++ b/docs/evidence/mods-2026-09-29.md @@ -4,8 +4,8 @@ [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 and -12:44–12:52. Battery 45 % → +`/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 @@ -66,7 +66,7 @@ page's **VR mod** dialog, driven with a headless Chromium: | `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; `start` also retries up to three times | +| 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 | @@ -102,10 +102,21 @@ is 47 MB. 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 12:52. +- The test lock was released at 13:03. diff --git a/docs/mods.md b/docs/mods.md index d6e7f91..8fb7a9c 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -26,7 +26,7 @@ means an upstream source describes it; “inferred” means it still needs a tes | 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.** Nine cold **Play in VR** runs (33–35 s each), then the full cycle again 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, 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. | | 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. | @@ -95,7 +95,7 @@ Why not UEVR's own injector (verified 2026-09-29): 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 9 of 9 cold starts, in 33–35 s. +- 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. diff --git a/tests/test_mods.py b/tests/test_mods.py index b0ad78f..3a656d9 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -159,12 +159,31 @@ class InstallUninstall(FakeLibrary): 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, "outside"): + 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) diff --git a/ui/frame_mods.py b/ui/frame_mods.py index 1aebbf7..482b2ea 100644 --- a/ui/frame_mods.py +++ b/ui/frame_mods.py @@ -69,18 +69,25 @@ 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) -k32.OpenProcess.restype = W.HANDLE -k32.VirtualAllocEx.restype = ctypes.c_void_p -k32.VirtualAllocEx.argtypes = [W.HANDLE, ctypes.c_void_p, ctypes.c_size_t, W.DWORD, W.DWORD] -k32.WriteProcessMemory.argtypes = [W.HANDLE, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p] -k32.CreateRemoteThread.restype = W.HANDLE -k32.CreateRemoteThread.argtypes = [W.HANDLE, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p, - W.DWORD, ctypes.c_void_p] -k32.GetModuleHandleW.restype = W.HMODULE -k32.GetProcAddress.restype = ctypes.c_void_p -k32.GetProcAddress.argtypes = [W.HMODULE, ctypes.c_char_p] -k32.LoadLibraryW.restype = W.HMODULE -k32.CreateToolhelp32Snapshot.restype = W.HANDLE +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), @@ -93,56 +100,68 @@ class Module(ctypes.Structure): # MODULEENTRY32W ("name", W.WCHAR * 256), ("path", W.WCHAR * 260)] def fail(msg): - print(json.dumps({"error": msg})); sys.exit(1) + print(json.dumps({"error": f"{msg} (Windows error {ctypes.get_last_error()})"})); sys.exit(1) -def find_pid(): - snap = k32.CreateToolhelp32Snapshot(2, 0) # TH32CS_SNAPPROCESS - e = Entry(); e.size = ctypes.sizeof(e) - ok = k32.Process32FirstW(snap, ctypes.byref(e)) - while ok: - if e.name.lower() == exe.lower(): - k32.CloseHandle(snap); return e.pid - ok = k32.Process32NextW(snap, ctypes.byref(e)) - k32.CloseHandle(snap) +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 module_base(pid, path): - snap = k32.CreateToolhelp32Snapshot(0x18, pid) # TH32CS_SNAPMODULE | SNAPMODULE32 - m = Module(); m.size = ctypes.sizeof(m) - ok = k32.Module32FirstW(snap, ctypes.byref(m)) - while ok: - if m.path.lower() == path.lower(): - k32.CloseHandle(snap); return m.base - ok = k32.Module32NextW(snap, ctypes.byref(m)) - k32.CloseHandle(snap) - -def remote_call(proc, fn, arg=None, wait_ms=10000): +def remote_call(proc, fn, arg, wait_ms): t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None) if not t: - fail(f"CreateRemoteThread failed ({ctypes.get_last_error()})") - k32.WaitForSingleObject(t, wait_ms); k32.CloseHandle(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 or not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None): - fail(f"couldn't write into {exe} ({ctypes.get_last_error()})") - remote_call(proc, k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW"), mem) - base = module_base(pid, path) - if not base: + 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, base + return path, m.base -pid = find_pid() -if not pid: +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} ({ctypes.get_last_error()})") + fail(f"couldn't open {exe}") path, base = inject(proc, pid, "UEVRPluginNullifier.dll") local = k32.LoadLibraryW(path) -offset = k32.GetProcAddress(local, b"nullify") - local # same DLL, same layout in both processes -remote_call(proc, base + offset, wait_ms=2000) +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) @@ -151,7 +170,8 @@ 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") -print(json.dumps({"pid": pid})) +k32.CloseHandle(proc) +print(json.dumps({"pid": pid, "nullified": nullified})) """ @@ -420,7 +440,15 @@ def start(appid): 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 = next((json.loads(l) for l in reversed(out.stdout.splitlines()) if l.startswith("{")), 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: @@ -446,15 +474,23 @@ def uninstall(appid): 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: # a receipt only ever points inside this game's prefix + for t in targets: real = os.path.realpath(t) - if not real.startswith(prefix + os.sep): - raise Fail(f"receipt lists a path outside the game's prefix: {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).startswith(prefix + os.sep): + if os.path.realpath(t) in parents: try: os.rmdir(t) except OSError: diff --git a/ui/server.py b/ui/server.py index 711d390..057425e 100755 --- a/ui/server.py +++ b/ui/server.py @@ -435,7 +435,7 @@ def steam(body): return steam_frame(action, appid) -# Downloads are about 70 MB; starting waits for the game, the injector and SteamVR. +# Downloads are about 20 MB; starting waits for the game, the injection and SteamVR. MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60} From 32fbbfe475124f01d96829de89b80312d6e4daa2 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:19:19 +1000 Subject: [PATCH 04/69] Fit the window at every size and show the battery once The page now scrolls between the header and the Activity bar on a computer, so the bar never covers content, and nothing overflows sideways from the 760 x 560 minimum up. The header gives way as the window narrows: the wordmark goes, the tabs tighten, and below 960 px the logo, Refresh (R still works) and the pill's second line go. Phones keep scrolling the window. The battery shows once, in the header chip. Clicking it opens a menu with the headset's storage, memory, temperature, Wi-Fi, uptime, SteamOS build and services; the big battery card is gone from Home, and the VR performance table no longer repeats battery and temperature. Heading rows wrap their buttons onto a new line instead of squeezing the heading, and every drop-down is dark. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/vr-utilities.md | 2 +- ui/index.html | 144 ++++++++++++++++++++++++++----------------- 2 files changed, 87 insertions(+), 59 deletions(-) diff --git a/docs/vr-utilities.md b/docs/vr-utilities.md index 0e73669..1c289b4 100644 --- a/docs/vr-utilities.md +++ b/docs/vr-utilities.md @@ -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. diff --git a/ui/index.html b/ui/index.html index 0d7a686..09870da 100644 --- a/ui/index.html +++ b/ui/index.html @@ -27,8 +27,10 @@ } * { box-sizing: border-box; } [hidden] { display: none !important; } /* class display rules would otherwise win */ - html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); } - body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom)); + /* On a computer the header and the Activity bar are the window's chrome: the page scrolls in #scroll + between them, so neither covers anything. Phones scroll the window (see the phone rules below). */ + body { margin: 0; color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden; + padding-bottom: calc(42px + env(safe-area-inset-bottom)); font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif; background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%), linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; } @@ -38,13 +40,14 @@ ::-webkit-scrollbar-track { background: transparent; } /* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */ - header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px; + #scroll { flex: 1; min-height: 0; overflow: auto; scroll-behavior: smooth; scroll-padding-top: 12px; } + header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px; flex: none; min-width: 0; height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px); box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); } - .brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; } + .brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; flex: none; } .brand svg { width: 30px; height: 30px; } .brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; } - nav { display: flex; gap: 4px; height: 100%; } + nav { display: flex; gap: 4px; height: 100%; flex: none; } nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none; font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase; border-bottom: 3px solid transparent; transition: color .15s; } @@ -58,10 +61,17 @@ .dot.off { background: var(--bad); } main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; } - .grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; } + .grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; align-items: start; } .grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; } - @media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } } - @media (max-width: 820px) { .brand b { display: none; } } + /* The header gives way as the window narrows: the wordmark goes, then the tabs tighten, then the + connection pill drops its second line. It fits down to the app's 760 px minimum, with room for + the macOS window buttons the app adds on the left. */ + header > button:not(.pill), header > .chip { flex: none; } + header > button:not(.pill):not(.chip) { width: 36px; padding: 0; justify-content: center; } + @media (max-width: 1250px) { .brand b { display: none; } } + @media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } } + @media (max-width: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; } + .brand, #refreshAll { display: none; } .pill .pt span { display: none; } } nav svg { display: none; } html { -webkit-text-size-adjust: 100%; } body { -webkit-tap-highlight-color: transparent; } @@ -87,7 +97,7 @@ .wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; } /* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */ - .pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); } + .pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 320px; min-width: 0; flex: 0 1 auto; background: var(--btn); } .pill .dot { flex: none; } .pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; } @keyframes pulse { 50% { opacity: .35; } } @@ -149,8 +159,9 @@ /* ---- panels and section headers ---- */ .panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%); border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; } - .shelf-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } - .shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); } + /* Buttons in a heading row wrap onto a line of their own rather than squeeze the heading. */ + .shelf-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 14px; } + .shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); white-space: nowrap; } .shelf-head .count { color: var(--muted); font-size: 13px; } .shelf-head .spacer { flex: 1; } .sub { color: var(--muted); font-size: 12.5px; } @@ -179,6 +190,11 @@ border-radius: 3px; padding: 9px 11px; font: inherit; } textarea { resize: vertical; min-height: 76px; } input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); } + /* Every drop-down dark, like the fields; the panels' own rules below still size them. */ + select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; + padding: 7px 8px; font: inherit; font-size: 13px; max-width: 100%; min-width: 0; } + select:focus { outline: none; border-color: var(--blue); } + option { background: #1e2329; color: var(--text); } .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } /* ---- headset view ---- */ @@ -223,14 +239,18 @@ @keyframes spin { to { transform: rotate(360deg); } } .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; } - /* ---- device ---- */ - .battery { display: flex; gap: 16px; align-items: center; padding: 16px; border-radius: 3px; background: rgba(0,0,0,.22); } - .battery svg { width: 74px; height: 38px; flex: none; } - .battery .pct { font-size: 34px; font-weight: 300; color: var(--bright); line-height: 1; font-variant-numeric: tabular-nums; } - .battery .state { font-weight: 700; font-size: 13px; letter-spacing: 1.2px; text-transform: uppercase; margin-top: 6px; } - .battery .state.charging { color: var(--green-hi); } .battery .state.full { color: var(--green); } - .battery .state.low { color: var(--bad); } .battery .state.draining { color: var(--muted); } - .battery .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; } + /* ---- the headset's details: a menu under the battery chip ---- */ + .pop { position: fixed; inset: auto; margin: 0; width: min(390px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto; + padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; background: #1e2329; color: var(--text); + box-shadow: 0 20px 60px rgba(0,0,0,.6); -webkit-app-region: no-drag; } + .pop .shelf-head { margin-bottom: 10px; } + .pop .stats { margin-top: 12px; } + .pop .chips { margin-top: 12px; } + .batt .state { font-weight: 700; font-size: 12.5px; letter-spacing: 1.2px; text-transform: uppercase; } + .batt .state.charging { color: var(--green-hi); } .batt .state.full { color: var(--green); } + .batt .state.low { color: var(--bad); } .batt .state.draining { color: var(--text); } + .batt .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; } + button.chip { height: 32px; padding: 0 12px; font-size: 13px; } .stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; } .stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; } .stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; } @@ -433,11 +453,14 @@ /* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */ @media (max-width: 640px), (max-height: 500px) and (hover: none) { - html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); } + html { scroll-behavior: smooth; scroll-padding-top: calc(64px + env(safe-area-inset-top)); } + body { height: auto; display: block; overflow: visible; } + #scroll { overflow: visible; } /* No blur here: it would make the header the containing block of the fixed tab bar. */ header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px; backdrop-filter: none; background: rgb(23, 29, 37); } .brand svg { width: 26px; height: 26px; } + .brand { display: flex; } #refreshAll { display: inline-flex; } .pill .pt span { display: block; } /* the tabs are at the bottom here */ header .chip { height: 30px; padding: 0 10px; } nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0; height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); @@ -648,7 +671,7 @@
- — + @@ -656,7 +679,21 @@ + +
+
@@ -1344,6 +1360,9 @@ function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&", function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; } function dur(s) { const d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60); return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; } +// What scrolls: #scroll on a computer, where the header and Activity bar stay put; the window on phones. +function scroller() { return getComputedStyle($("scroll")).overflowY === "visible" ? document.scrollingElement : $("scroll"); } +function scrollToY(y) { scroller().scrollTo({ top: y, behavior: "instant" }); } function meter(el, pct, warnAt, badAt) { el.style.width = Math.max(0, Math.min(100, pct)) + "%"; el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : ""; @@ -1464,7 +1483,7 @@ function setOnline(ok, why) { if (!ok) { $("offMsg").textContent = why || "Can't reach the Frame"; setConn(false, "Offline"); - $("battChip").hidden = true; + $("battChip").hidden = true; closeDevPop(); if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); } if (window.connBanner) window.connBanner(); } else if (was === false) { @@ -1512,7 +1531,7 @@ function renderPerformance(s) { const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"], ["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"], ["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"], - ["GPU clock", p.gpuMHz, " MHz"], ["Hottest sensor", s?.temp, "°C"], ["Battery", s?.battery?.percent, "%"]]; + ["GPU clock", p.gpuMHz, " MHz"]]; $("vrMetrics").innerHTML = values.map(([label, v, unit]) => `
${label}
${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}
`).join(""); $("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample"; } @@ -1644,10 +1663,6 @@ function battery(b, power) { const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0); const low = !charging && !full && pct != null && pct <= 15; const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf"; - $("battFill").setAttribute("width", pct == null ? 0 : (55 * Math.min(100, Math.max(0, pct)) / 100).toFixed(1)); - $("battFill").setAttribute("fill", color); - $("battBolt").setAttribute("visibility", charging ? "visible" : "hidden"); - $("battPct").textContent = pct == null ? "—" : `${pct}%`; let label, cls, detail = []; if (!b) { label = "Battery unknown"; cls = "draining"; } @@ -1672,13 +1687,25 @@ function battery(b, power) { const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct)); $("battChip").innerHTML = pct == null ? "—" : `${bolt}${pct}%`; - $("battChip").title = label; + $("battChip").title = `${label}. Click for the headset's details.`; + $("battChip").setAttribute("aria-label", `Battery ${pct}%, ${label}. Headset details`); $("battChip").hidden = pct == null; + if (pct == null) closeDevPop(); } +// hidePopover throws where the menu isn't open (and doesn't exist in older browsers). +function closeDevPop() { try { $("devPop").hidePopover(); } catch {} } +// The battery chip's menu opens under the chip, right-aligned with it. +$("devPop").addEventListener("beforetoggle", e => { + if (e.newState !== "open") return; + const r = $("battChip").getBoundingClientRect(); + $("devPop").style.top = `${Math.round(r.bottom + 8)}px`; + $("devPop").style.right = `${Math.max(12, Math.round(innerWidth - r.right))}px`; +}); function render(s) { renderPerformance(s); battery(s.battery, s.power); + $("devPopTitle").textContent = window.connName || "Headset"; $("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`; $("updated").textContent = new Date(s.time * 1000).toLocaleTimeString(); const h = s.disk.home; @@ -2695,7 +2722,7 @@ function showApkAlternatives(reason, result, dev = window.connDevice) { }, dev)); if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; } await loadAndroid(); - if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); } + if (cat.apps) { const y = scroller().scrollTop; filterCatalog(); scrollToY(y); } if (res && res.app) await offerTest(res.app); }; if (!$("apkAltDlg").open) $("apkAltDlg").showModal(); @@ -2929,7 +2956,7 @@ function renderChips() { } function filterGames(keepScroll) { - const y = window.scrollY; + const y = scroller().scrollTop; if (gm.src === "owned") { if (!gm.owned) return; const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean); @@ -2945,7 +2972,7 @@ function filterGames(keepScroll) { $("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n); if (!gm.results.length) $("gmGrid").innerHTML = `
${gm.src === "store" && !gm.storeQ ? "Type a name and press Enter to search the Steam store." : "Nothing matches. Try turning on more ratings above."}
`; - if (keepScroll) window.scrollTo(0, y); + if (keepScroll) scrollToY(y); } function price(p) { @@ -3300,7 +3327,7 @@ document.body.addEventListener("click", async e => { if (action === "probe") { loadCatalog(); loadReports(); } } loadAndroid(); - if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); } + if (cat.apps) { const y = scroller().scrollTop; filterCatalog(); scrollToY(y); } }); loadCatalog(); @@ -3695,7 +3722,7 @@ function showPage() { document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page)); document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`; const section = !PAGES.includes(id) && id && $(id); - if (section) section.scrollIntoView(); else window.scrollTo(0, 0); + if (section) section.scrollIntoView(); else scrollToY(0); if (page === "tools") { loadMacView(); loadPanels(); } } window.addEventListener("hashchange", showPage); @@ -4286,6 +4313,7 @@ function onConnection(s) { renderPill(); renderConnDlg(); const devId = s.device && s.device.id; window.connDevice = devId || null; + window.connName = s.device ? s.device.name : null; if (link.device !== null && devId !== link.device) { log(`Now using ${s.device.name}`, "ok"); state = null; @@ -4315,7 +4343,7 @@ function onConnection(s) { if ($("wiDlg").open && !wi.job && !wi.starting) $("wiDlg").close(); $("dispSel").innerHTML = ""; $("dispSel").disabled = true; $("dispCtl").hidden = true; link.reload = true; // everything on the page was the other headset's - $("battChip").hidden = true; + $("battChip").hidden = true; closeDevPop(); } if (s.phase !== link.phase || devId !== link.device) { if (s.phase === "connected") { From cb0a90a9c39f5fa0f5f89ab30540e612309bf429 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:36:13 +1000 Subject: [PATCH 05/69] Change the headset's addresses from the connection pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill says plainly whether the headset is connected and how ("Connected · Tailscale", or the network's name), and never shows a truncated address. Its dialog now leads with that, then lists the headset's addresses with what each answered; they can be added, edited, reordered and removed right there, and a new one is tested straight away. When the Frame reports a LAN IP on this computer's network that isn't saved, it offers to add it, so at home the app connects directly rather than over Tailscale. The connection steps and this computer's network fold away while it's connected and open when it isn't. Retry now and Set Up Connection only show when they'd help; the buttons stay in reach when the dialog scrolls. On the Devices tab the SSH alias, user, port and Forget identity move under Advanced, and the address kind is worked out from the address. Report a problem is a speech bubble rather than a warning sign. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- docs/devices.md | 16 ++- docs/frame-control.md | 6 +- docs/privacy.md | 2 +- ui/index.html | 271 ++++++++++++++++++++++++++++++++---------- 5 files changed, 227 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index fb40d32..1fbfcd5 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/devices.md b/docs/devices.md index 1ed3200..847d8b7 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -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,12 @@ 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, so at home the app connects directly rather +than over Tailscale. A new or edited address is tested straight away. + ## Networks A network is told apart by its default gateway: the router's IP address plus its @@ -122,7 +129,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 diff --git a/docs/frame-control.md b/docs/frame-control.md index 3ec3b4c..eedc055 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -22,9 +22,9 @@ The window has five tabs: **Home** (headset view, status, screenshots), 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 +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. diff --git a/docs/privacy.md b/docs/privacy.md index db7004f..51b7d9a 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -97,7 +97,7 @@ 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 +**Report a problem** is the speech-bubble button in the header, also in the Privacy panel 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 diff --git a/ui/index.html b/ui/index.html index 09870da..1e15f1b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -71,7 +71,8 @@ @media (max-width: 1250px) { .brand b { display: none; } } @media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } } @media (max-width: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; } - .brand, #refreshAll { display: none; } .pill .pt span { display: none; } } + .brand, #refreshAll { display: none; } + .pill .pt > b, .pill .via { display: none; } .pill .pt > span { font-size: 13px; color: var(--bright); } } nav svg { display: none; } html { -webkit-text-size-adjust: 100%; } body { -webkit-tap-highlight-color: transparent; } @@ -103,7 +104,27 @@ @keyframes pulse { 50% { opacity: .35; } } .pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; } .pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .pill .pt > span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .conn-now { display: flex; gap: 10px; align-items: baseline; } + .conn-now .dot { position: relative; top: -1px; } + .conn-now b { color: var(--bright); font-weight: 500; font-size: 15px; } + .conn-offer { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 14px; padding: 10px 12px; border-radius: 4px; + background: rgba(26,159,255,.1); border-left: 3px solid var(--blue); font-size: 13px; } + .conn-offer .grow { flex: 1 1 260px; } + .h3-note { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 6px; } + #connAddrs .item { gap: 6px; } + #connAddrs .t { font-size: 13.5px; overflow-wrap: anywhere; } + .conn-add { grid-template-columns: 2fr 1.2fr auto; } + .conn-edit { grid-template-columns: 2fr 1.2fr auto auto; } + .addr-add button.small, .dev-name button.small { height: 40px; } + .dev-name { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; max-width: 520px; } + .adv { margin-top: 18px; border-top: 1px solid rgba(255,255,255,.06); padding-top: 4px; } + .adv .dev-form { grid-template-columns: 1.3fr 1fr .6fr; } + .conn-more { margin-top: 16px; } + .conn-more summary { margin-top: 0; } + /* The dialog scrolls when the steps are open in a short window; its buttons stay in reach. */ + #connDlg .rep-actions { position: sticky; bottom: -22px; margin: 16px -22px -22px; padding: 12px 22px; + background: #1e2329; border-top: 1px solid rgba(255,255,255,.06); } .devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; } .dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); } @@ -144,8 +165,8 @@ .addr .t { overflow-wrap: anywhere; } .addr .s { white-space: normal; } .addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; } - .addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; } - @media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } } + .addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; } + @media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } } .found { margin-top: 10px; } /* ---- drop anywhere ---- */ @@ -460,7 +481,8 @@ header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px; backdrop-filter: none; background: rgb(23, 29, 37); } .brand svg { width: 26px; height: 26px; } - .brand { display: flex; } #refreshAll { display: inline-flex; } .pill .pt span { display: block; } /* the tabs are at the bottom here */ + .brand { display: flex; } #refreshAll { display: inline-flex; } /* the tabs are at the bottom here */ + .pill .pt > b { display: block; } .pill .via { display: inline; } .pill .pt > span { font-size: 11.5px; color: var(--muted); } header .chip { height: 30px; padding: 0 10px; } nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0; height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); @@ -672,8 +694,8 @@ -

Connection

-
-

This computer

-
-

Steps

-
    -

    Addresses

    -
    +
    +
    + +

    Addresses Frame Control tries them all at once; the first in the list wins a tie

    +
    + + + + +
    Connection steps +
      +

      This computer

      +
      +
      - - - -
      + + + +
      @@ -4233,28 +4262,36 @@ function netLabel(s) { } function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; } -// The pill in the header: always there, and says what's happening right now. +// How the headset is reached, in a word: Tailscale, the network's own name, or this network. +function routeLabel(s) { + if (s.via && s.via.kind === "tailscale") return "Tailscale"; + const named = s.network && dv.data && dv.data.networks.find(x => x.id === s.network.id && x.name); + return named ? named.name : "this network"; +} +// The pill in the header: always there, and says plainly what's happening. Addresses are in its +// dialog, where there's room for them; the tooltip has the detail. function renderPill() { const s = link.s; if (!link.live || !s) return false; const dev = s.device ? s.device.name : "Frame"; - let dot = "wait", line; + let dot = "wait", word, via, detail; if (s.phase === "connected") { - dot = "on"; - line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : ""); + dot = "on"; word = "Connected"; via = routeLabel(s); + detail = `connected via ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""); } else if (s.phase === "failed") { - dot = "off"; + dot = "off"; word = "Offline"; const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null; - const st = (s.stages || []).find(x => x.id === (s.error || {}).stage); - line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : ""); + via = left != null ? `retrying in ${left} s` : ""; + detail = s.error ? s.error.message : "not connected"; } else { + word = "Connecting…"; const st = activeStage(s); - const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving"); - line = st ? st.label + (st.id === "find" && probing ? `: ${probing.host}` : st.detail && st.id !== "network" ? `: ${st.detail}` : "…") - : "Connecting…"; + via = st ? st.label.toLowerCase() : ""; + detail = via || "connecting"; } - $("conn").innerHTML = `${esc(dev)}${esc(line)}`; - $("conn").title = `${dev}: ${line}. Click for details.`; + $("conn").innerHTML = `${esc(dev)}${esc(word)}` + + `${via ? ` · ${esc(via)}` : ""}`; + $("conn").title = `${dev}: ${detail}. Click to see or change its addresses.`; return true; } window.connPill = renderPill; @@ -4278,17 +4315,27 @@ function renderBanner() { } window.connBanner = renderBanner; +// The pill's dialog: how it's connected, and the headset's addresses, which can be added, +// edited and reordered right here. The steps it took stay folded away while all is well. +const cd = { editing: null, sig: "", phase: null }; function renderConnDlg() { const s = link.s; if (!s || !$("connDlg").open) return; - $("connTitle").textContent = s.device ? `${s.device.name} (ssh ${s.device.alias})` : "Connection"; - $("connWhy").textContent = s.phase === "connected" ? `Connected via ${viaText(s.via)}${s.via && s.via.why ? " (" + s.via.why + ")" : ""}` - : s.phase === "failed" ? (s.error ? s.error.message : "Not connected") + $("connTitle").textContent = s.device ? s.device.name : "Connection"; + const connected = s.phase === "connected", failed = s.phase === "failed"; + $("connDot").className = "dot " + (connected ? "on" : failed ? "off" : "wait"); + $("connState").textContent = connected ? `Connected via ${routeLabel(s)}` : failed ? "Offline" : "Connecting…"; + $("connWhy").textContent = connected ? viaText(s.via) + (s.via && s.via.rtt_ms != null ? ` · answered in ${s.via.rtt_ms} ms` : "") + : failed ? (s.error ? s.error.message : "Not connected") : `${s.reason || "Connecting"}…`; + // Folded while connected; opened when something's wrong. Left as the user set it otherwise. + if (s.phase !== cd.phase) { $("connMore").open = !connected; cd.phase = s.phase; } + $("connRetryBtn").hidden = !failed; + $("connRetryBtn").classList.toggle("action", failed); + $("connSetup").hidden = connected; const n = s.network || {}, ts = n.tailscale || {}; - const facts = [["Network", n.name || "—"], ["Wi-Fi", n.ssid || (n.wifi ? "Wi-Fi (name hidden by the system)" : n.wifi === false ? "Wired" : "—")], - ["Router", n.gateway ? `${n.gateway}${n.gateway_mac ? " · " + n.gateway_mac : ""}` : "No default route"], - ["This computer", n.local_ip || "—"], + const facts = [["Network", n.ssid || n.name || "—"], + ["This computer", n.local_ip || "—"], ["Router", n.gateway || "No default route"], ["Tailscale", ts.up ? `On${ts.ip ? " · " + ts.ip : ""}` : ts.installed ? "Installed, not running" : "Not installed"]]; $("connNet").innerHTML = facts.map(([k, v]) => `
      ${esc(k)}
      ${esc(v)}
      `).join(""); $("connStages").innerHTML = (s.stages || []).map(st => `
    1. ${STAGE_ICON[st.state] || ""} @@ -4297,14 +4344,108 @@ function renderConnDlg() { ${st.detail ? `${esc(st.detail)}` : ""}
    2. `).join("") + (s.phase === "connected" ? `
    3. ✓Connected ${esc(`via ${netLabel(s)}, ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""))}
    4. ` : ""); - $("connProbes").innerHTML = (s.probes || []).map(p => `${esc(p.host)}${p.label ? `
      ${esc(p.label)}
      ` : ""} - ${esc(KIND_TAG[p.kind] || p.kind)}${esc(p.why || "")} - ${esc(p.detail)}${p.ip && p.ip !== p.host ? ` ${esc(p.ip)}` : ""}`).join("") - || `No addresses tried yet.`; + renderConnAddrs(); const left = s.phase === "failed" && s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null; $("connRetry").textContent = left != null ? `Trying again in ${left} s` : s.finished && s.phase === "connected" ? `Connected ${ago(s.finished)}` : ""; } +const PRIVATE_V4 = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)\d{1,3}\.\d{1,3}(\.\d{1,3})?$/; +const sameSubnet = (a, b) => !!a && !!b && a.split(".").slice(0, 3).join(".") === b.split(".").slice(0, 3).join("."); +// The Frame's own LAN address, when this computer is on the same network and it isn't saved yet: +// saving it lets Frame Control connect directly at home rather than over Tailscale. +function lanOffer(s, d) { + const ip = state && state.ip, n = s.network || {}; + if (!d || d.transient || !ip || !PRIVATE_V4.test(ip) || !sameSubnet(ip, n.local_ip)) return null; + if (d.addresses.some(a => a.host === ip) || (s.probes || []).some(p => p.ip === ip && p.kind !== "tailscale")) return null; + const named = dv.data && dv.data.networks.find(x => x.id === n.id && x.name); + return { ip, label: named ? named.name : n.ssid || "" }; +} +function activeDevice() { const s = link.s; return s && s.device ? dv.list.find(x => x.id === s.device.id) : null; } +function renderConnAddrs() { + const s = link.s, d = activeDevice(), list = $("connAddrs"); + const offer = s && lanOffer(s, d); + $("connOffer").hidden = !offer; + if (offer) { + const key = offer.ip + offer.label; + if ($("connOffer").dataset.key !== key) { + $("connOffer").dataset.key = key; + $("connOffer").innerHTML = `
      The Frame is at ${esc(offer.ip)} on this network. Add it for this + network? Frame Control will then connect to it directly whenever you're here.
      + `; + $("connOfferAdd").onclick = e => devAction(`Add ${offer.ip}`, { action: "address-add", id: d.id, host: offer.ip, + kind: "lan", label: offer.label }, e.currentTarget) + .then(res => res && testQuietly(d.id)); + } + } else $("connOffer").dataset.key = ""; + $("connAdd").hidden = !d || !!d.transient; + if (!d) { list.innerHTML = `
      Loading…
      `; cd.sig = ""; return; } + if (d.transient) { + list.innerHTML = `
      This headset isn't set up in Frame Control yet, so ssh's own settings decide where it goes. + Set Up Connection adds its addresses here.
      `; + cd.sig = ""; return; + } + const probes = new Map((s.probes || []).map(p => [p.host, p])); + const tested = new Map((((s.tests || {})[d.id] || {}).rows || []).map(r => [r.host, r])); + const inUse = s.phase === "connected" && s.via ? s.via.host : null; + const sig = JSON.stringify([d.id, d.addresses, [...probes.values(), ...tested.values()].map(p => [p.host, p.state, p.ssh, p.detail]), cd.editing, inUse]); + // Leave the rows alone when nothing has changed, and while an address is being edited. + if (sig === cd.sig || cd.editing && list.contains(document.activeElement) && document.activeElement.matches("input")) return; + cd.sig = sig; + const refocus = cd.refocus; // after an edit, back to that row's Edit button + cd.refocus = null; + list.innerHTML = d.addresses.map((a, i) => { + if (cd.editing === a.host) return `
      + + +
      `; + const p = probes.get(a.host), t = tested.get(a.host); + const result = a.host === inUse ? `In use${p && p.detail ? " · " + esc(p.detail.toLowerCase()) : ""}` + : t ? `${esc(t.detail)}${t.ssh === "checking" ? " · checking SSH…" : ""}` + : p ? `${esc(p.detail)}` + : a.last_ok ? `Worked ${esc(ago(a.last_ok))}` : "Not tried on this network"; + return `
      +
      ${esc(a.host)} ${esc(KIND_TAG[a.kind] || a.kind)}${a.label ? ` ${esc(a.label)}` : ""}
      +
      ${result}
      + + +
      `; + }).join("") || `
      No addresses yet. Add the Frame's IP address below.
      `; + if (refocus) list.querySelector(`[data-host="${CSS.escape(refocus)}"] [data-ed]`)?.focus(); + list.querySelectorAll("[data-host]").forEach(row => { + const host = row.dataset.host; + row.querySelectorAll("[data-mv]").forEach(b => b.onclick = () => + devAction("Move " + host, { action: "address-move", id: d.id, host, delta: +b.dataset.mv }, b)); + row.querySelector("[data-ed]").onclick = () => { cd.editing = host; renderConnAddrs(); $("ceHost").focus(); }; + row.querySelector("[data-rm]").onclick = e => { + if (host === inUse && !confirm(`${host} is how Frame Control is reaching the headset now. Remove it?`)) return; + devAction(`Remove ${host}`, { action: "address-remove", id: d.id, host }, e.currentTarget); + }; + }); + if ($("ceSave")) { + const host = cd.editing, a = d.addresses.find(x => x.host === host); + const save = e => { + e.preventDefault(); + const newHost = $("ceHost").value.trim(), label = $("ceLabel").value; + cd.editing = null; cd.refocus = newHost || host; + devAction(`Save ${host}`, { action: "address-update", id: d.id, host, newHost, kind: a.kind, label }, $("ceSave")) + .then(res => res && testQuietly(d.id)); + }; + $("ceSave").onclick = save; + [$("ceHost"), $("ceLabel")].forEach(i => i.onkeydown = e => { if (e.key === "Enter") save(e); if (e.key === "Escape") { e.preventDefault(); $("ceCancel").click(); } }); + $("ceCancel").onclick = () => { cd.editing = null; cd.sig = ""; cd.refocus = host; renderConnAddrs(); }; + } +} +$("connAdd").onsubmit = async e => { + e.preventDefault(); + const d = activeDevice(), host = $("caHost").value.trim(); + if (!d) return; + if (!host) return $("caHost").focus(); + const res = await devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "", label: $("caLabel").value }); + if (res) { $("caHost").value = ""; $("caLabel").value = ""; testQuietly(d.id); } +}; +// Try the addresses (without reconnecting), so a new one shows whether it answers. +function testQuietly(id) { api("/api/devices", { action: "test", id }).catch(() => {}); } + function onConnection(s) { const prev = link.s; link.s = s; link.live = !s.local; link.lastData = Date.now(); @@ -4401,7 +4542,9 @@ setInterval(() => { if (link.live && link.s && link.s.phase === "failed") { rend $("conn").onclick = () => { if (!link.live) { if (online === false) refresh(); return; } + cd.editing = null; cd.sig = ""; cd.phase = null; $("connDlg").showModal(); renderConnDlg(); + if (!dv.data) loadDevices(); }; $("offDetails").onclick = () => $("conn").click(); $("connClose").onclick = () => $("connDlg").close(); @@ -4460,7 +4603,7 @@ async function devAction(label, body, btn) { function deviceStatus(d) { const s = link.s; if (d.active && s) { - return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`] + return s.phase === "connected" ? ["on", `Connected via ${routeLabel(s)}`] : s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"]; } const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0)); @@ -4474,7 +4617,7 @@ function renderDevices() { const [dot, text] = deviceStatus(d); return `
      ${esc(d.name)} ${d.active ? 'In use' : ""}
      -
      ssh ${esc(d.alias)} · ${esc(text)}
      +
      ${esc(text)}
      ${d.active ? "" : ``}`; }).join("") || `
      No headsets yet. Add one to get started.
      `; $("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => { @@ -4489,6 +4632,7 @@ function renderDevices() { $("netName").disabled = $("netSave").disabled = !(n && n.id); } renderDetail(); + renderConnDlg(); // the pill's dialog lists the same addresses } $("netSave").onclick = e => { const n = dv.data && dv.data.network; @@ -4526,19 +4670,13 @@ function renderDetail() { `).join(""); el.innerHTML = `

      ${esc(d.name)}

      ${d.active ? 'In use' : ""} ${d.active ? "" : ``}
      - + - - - -
      -
      - User and port are written to its block in ~/.ssh/config too.
      -

      Addresses tried all at once; this order breaks ties

      + +

      Addresses tried all at once; the first in the list wins a tie

      ${rows || '
      No addresses yet: add one, or find it below.
      '}
      - ${kindSel("adKind", "")}
      @@ -4546,22 +4684,33 @@ function renderDetail() { - -
      +
      -
      Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused." - : "not saved yet; the next connection saves it."} Kind "auto" is picked from the address: - .local names are mDNS, 100.x and .ts.net are Tailscale.
      `; - $("adKind").insertAdjacentHTML("afterbegin", ``); - $("adKind").value = ""; +
      Advanced +
      + + + +
      +
      + In Terminal it's ssh ${esc(d.alias)}. User and port are written to its block in ~/.ssh/config too.
      +
      + Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused." + : "not saved yet; the next connection saves it."}
      +
      An address's kind is worked out from it: .local names are mDNS, 100.x and + .ts.net are Tailscale, and other IPs are this network. Edit an address to set it yourself.
      +
      `; + el.querySelector(".adv").ontoggle = e => { dv.adv = e.target.open; }; if ($("dvUse")) $("dvUse").onclick = () => useDevice(d.id); - $("dvSave").onclick = e => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value, - user: $("dvUser").value, port: $("dvPort").value }, e.currentTarget); + const saveDev = btn => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value, + user: $("dvUser").value, port: $("dvPort").value }, btn); + $("dvSave").onclick = e => saveDev(e.currentTarget); + $("dvNameForm").onsubmit = e => { e.preventDefault(); saveDev(e.submitter); }; $("dvAdd").onsubmit = e => { e.preventDefault(); const host = $("adHost").value.trim(); if (!host) return $("adHost").focus(); - devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: $("adKind").value, label: $("adLabel").value }); + devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "", label: $("adLabel").value }); }; el.querySelectorAll("[data-host]").forEach(row => { const host = row.dataset.host; From ed4a0ad5cd2c2f3a706848425d140bb0a1157f2f Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:48:57 +1000 Subject: [PATCH 06/69] Lead Home with what's happening, and give settings a page Home answers "what's my headset doing, and what do I want to do now". A Right now card says whether SteamVR is running and holds the volume and the next things people do: cast to this screen, play a video or photo, show a Mac window, send files, type on the Frame. With nothing captured, the headset view is a short strip rather than a big black box; the keyboard and trackpad fold to one line until turned on; VR performance folds away. Screenshots and Family and comfort follow. Tools is grouped: in the headset (Mac in the headset, the media player, the panel switcher), then sending files, Linux apps, remote and power. Privacy and updates, library artwork and the assistant move to a Settings page, opened by a gear in the header (key 6), with About and licences. Family and comfort no longer fills the form with a finished session's settings, so it shows 30 / 20 / 30 rather than the last test's 1 / 0 / 0, and its recent events carry their times. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/agents.md | 2 +- docs/family-comfort.md | 2 +- docs/frame-control.md | 17 ++- docs/iphone.md | 4 +- docs/panels.md | 2 +- docs/privacy.md | 2 +- docs/vr-apks.md | 2 +- docs/vr-utilities.md | 2 +- docs/vr-video.md | 2 +- tests/test_comfort_ui.py | 30 ++++ ui/index.html | 292 ++++++++++++++++++++++++--------------- 11 files changed, 231 insertions(+), 126 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 76ee9c1..20cc82f 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 diff --git a/docs/family-comfort.md b/docs/family-comfort.md index e2f1afe..98b94b5 100644 --- a/docs/family-comfort.md +++ b/docs/family-comfort.md @@ -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 diff --git a/docs/frame-control.md b/docs/frame-control.md index eedc055..4c10fe9 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -17,11 +17,16 @@ 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 +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 @@ -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. diff --git a/docs/iphone.md b/docs/iphone.md index debc954..6381799 100644 --- a/docs/iphone.md +++ b/docs/iphone.md @@ -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). diff --git a/docs/panels.md b/docs/panels.md index 5b17828..6c8478b 100644 --- a/docs/panels.md +++ b/docs/panels.md @@ -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). diff --git a/docs/privacy.md b/docs/privacy.md index 51b7d9a..141f76d 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -98,7 +98,7 @@ The same error is sent at most once every 10 minutes. ## Report a problem **Report a problem** is the speech-bubble button in the header, also in the -Privacy panel and under **Help → Report a Problem…**. It sends the report +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 diff --git a/docs/vr-apks.md b/docs/vr-apks.md index bce6817..38ba7ff 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -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 diff --git a/docs/vr-utilities.md b/docs/vr-utilities.md index 1c289b4..6e2db25 100644 --- a/docs/vr-utilities.md +++ b/docs/vr-utilities.md @@ -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 diff --git a/docs/vr-video.md b/docs/vr-video.md index 2232915..eab6080 100644 --- a/docs/vr-video.md +++ b/docs/vr-video.md @@ -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. diff --git a/tests/test_comfort_ui.py b/tests/test_comfort_ui.py index 2d75b36..c990bc2 100644 --- a/tests/test_comfort_ui.py +++ b/tests/test_comfort_ui.py @@ -55,6 +55,36 @@ 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 => { + if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value:'30', checked: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, '30'); +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); ''' result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) diff --git a/ui/index.html b/ui/index.html index 1e15f1b..59a80c6 100644 --- a/ui/index.html +++ b/ui/index.html @@ -72,7 +72,7 @@ @media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } } @media (max-width: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; } .brand, #refreshAll { display: none; } - .pill .pt > b, .pill .via { display: none; } .pill .pt > span { font-size: 13px; color: var(--bright); } } + .pill .pt > b, .pill .via { display: none; } header .pill .pt > span { font-size: 13px; color: var(--bright); } } nav svg { display: none; } html { -webkit-text-size-adjust: 100%; } body { -webkit-tap-highlight-color: transparent; } @@ -233,6 +233,10 @@ .zoombar button:hover { background: rgba(255,255,255,.12); } .zoombar .lvl { min-width: 46px; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12px; } .viewer .empty { text-align: center; color: var(--muted); padding: 24px; max-width: 460px; } + /* Nothing captured yet: a short strip rather than a big black box. */ + .viewer:has(> #viewerEmpty:not([hidden])) { aspect-ratio: auto !important; height: 190px; + background: radial-gradient(500px 160px at 50% 0%, rgba(42,71,94,.55), transparent 70%), #0b1016; } + .viewer:has(> #viewerEmpty:not([hidden])) .empty svg { width: 44px; height: 44px; margin-bottom: 4px; } .viewer .empty svg { width: 64px; height: 64px; opacity: .35; margin-bottom: 10px; } .viewer .badge { position: absolute; left: 12px; top: 12px; font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase; background: rgba(0,0,0,.6); color: var(--text); padding: 4px 9px; border-radius: 2px; } @@ -260,6 +264,26 @@ @keyframes spin { to { transform: rotate(360deg); } } .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; } + /* ---- Home: what the headset's doing, and what you'd do next ---- */ + .now-state { margin: 0 0 4px; color: var(--bright); font-size: 15px; } + .now-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 16px; } + .now-actions button { justify-content: center; height: 38px; white-space: normal; line-height: 1.2; text-align: center; } + .now-actions button:first-child { grid-column: 1 / -1; } + @media (max-width: 1150px) { .now { order: -1; } } /* stacked: what's happening comes first */ + #padIntro { margin: 0; } + .group-head { margin: 4px 0 -8px; } + a.button-link { display: inline-flex; align-items: center; height: 34px; padding: 0 14px; border-radius: 3px; background: var(--btn); + color: var(--text); text-decoration: none; } + a.button-link:hover { background: var(--btn-hi); color: var(--bright); } + #settingsBtn.on { color: var(--blue); } + /* A panel that folds: its heading row is the summary. */ + .fold > summary { list-style: none; cursor: pointer; margin: 0; color: inherit; font-size: inherit; } + .fold > summary::-webkit-details-marker { display: none; } + .fold > summary h2::before { content: "▸"; display: inline-block; width: 1.1em; color: var(--muted); } + .fold[open] > summary h2::before { content: "▾"; } + .fold:not([open]) > summary { margin-bottom: 0; } + .fold > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 4px; } + /* ---- the headset's details: a menu under the battery chip ---- */ .pop { position: fixed; inset: auto; margin: 0; width: min(390px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto; padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; background: #1e2329; color: var(--text); @@ -481,8 +505,8 @@ header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px; backdrop-filter: none; background: rgb(23, 29, 37); } .brand svg { width: 26px; height: 26px; } - .brand { display: flex; } #refreshAll { display: inline-flex; } /* the tabs are at the bottom here */ - .pill .pt > b { display: block; } .pill .via { display: inline; } .pill .pt > span { font-size: 11.5px; color: var(--muted); } + .brand { display: flex; } /* the tabs are at the bottom here; Refresh is automatic */ + .pill .pt > b { display: block; } .pill .via { display: inline; } header .pill .pt > span { font-size: 11.5px; color: var(--muted); } header .chip { height: 30px; padding: 0 10px; } nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0; height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); @@ -697,6 +721,9 @@ + @@ -805,8 +832,9 @@
      -
      -

      Volume

      +
      +

      Right now

      +

      Waiting for the Frame…

      +
      + + + + + +
      +

      Casting shows everything the wearer sees, including anything private.

      -
      -

      VR comfort and performance

      Waiting for the Frame
      -
      -
      Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Battery and temperature are in the battery menu at the top.
      -
      - - +
      +

      Screenshots

      + + +
      -
      The HUD is ours and uses the Frame’s built-in tools. Place its panel with SteamVR’s Float in World or controller docking controls. It updates every two seconds until closed.
      -

      Playspace and seated comfort

      -

      Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode.

      -
      For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.
      -
      Optional VR software -

      None of Frame Control’s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.

      - -
      Load this optional list to check the Frame account.
      -
      +
      Loading…
      +
      Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
      -
      -

      Family and comfort

      - -
      -

      Share this screen with people in the room. Casting shows everything the wearer sees, including private content.

      +
      +

      Family and comfort

      Session timer and reminders
      @@ -870,6 +894,9 @@

      Keyboard and trackpad

      +

      Use this computer's keyboard and mouse in apps on the Frame, such as Chromium or the + desktop's Linux apps.

      + +
      -
      -

      Screenshots

      - - - +
      +

      VR performance

      Frame timing, the HUD, playspace and optional software + Waiting for the Frame
      +
      +
      Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Battery and temperature are in the battery menu at the top.
      +
      + +
      -
      Loading…
      -
      Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
      -
      +
      The HUD is ours and uses the Frame’s built-in tools. Place its panel with SteamVR’s Float in World or controller docking controls. It updates every two seconds until closed.
      +

      Playspace and seated comfort

      +

      Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode.

      +
      For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.
      +
      Optional VR software +

      None of Frame Control’s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.

      + +
      Load this optional list to check the Frame account.
      +
      + +
      @@ -1024,7 +1063,64 @@
      -

      Assistant and AI agents

      Use your own model endpoint, or review a proposed MCP action. Nothing is sent to a model until you opt in.

      Open assistant
      +

      In the headset

      Show your computer, films and photos, and panels
      +
      + + +
      +

      Media player

      Preview
      +

      Watch your own films and stereo photos on a big screen in the headset.

      +
      +
      + +
      +
      +
      +
      +
      +
      +
      +

      Choose a file to begin.

      +
      H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head. + Small .splat files show a stationary stereo preview (up to 20,000 Gaussians). + Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet. + Stop works from here; playback also ends after four hours.
      +
      + +
      +

      Panel switcher

      + +
      +
      Refresh to see open panels.
      +
      Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard. + Saved spatial layouts are not available yet.
      +
      +
      +

      Send and manage

      Files, clipboard, Linux apps, remote access and power

      Send to Frame

      @@ -1051,31 +1147,6 @@
      Clipboard needs the desktop panel open in the headset.
      -
      -

      Media in the headset

      Preview
      -

      Play your movies and stereo photos with Frame Control's own player.

      -
      -
      - -
      -
      -
      -
      -
      -
      -
      -

      Choose a file to begin.

      -
      H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head. - Small .splat files show a stationary stereo preview (up to 20,000 Gaussians). - Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet. - Stop works from here; playback also ends after four hours.
      -
      -

      Linux apps

      Flatpak
      Loading…
      @@ -1086,20 +1157,6 @@
      - -
      -

      Library artwork settings

      -

      SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.

      - - -
      - - - Get a free key -
      -
      - -

      Remote & power

      @@ -1114,44 +1171,17 @@
      Sleep, Restart and Shut down open a terminal window for the Developer Mode password.
      Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.
      -
      - -
      -

      Panel switcher

      - -
      -
      Refresh to see open panels.
      -
      Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard. - Saved spatial layouts are not available yet.
      -
      - - - +
      +
      +
      +

      Settings

      +

      Privacy & updates

      + +
      +

      Library artwork

      +

      SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.

      + + +
      + + + Get a free key +
      +
      + +
      + +
      +

      Assistant and AI agents

      +

      Use your own model endpoint, and review what an AI agent asks to do over MCP before it happens. Nothing is + sent to a model until you turn it on.

      + +
      @@ -1430,6 +1481,7 @@ function applyHostWording(h) { $("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`; $("clipMac").textContent = `Send ${h.computer} clipboard`; if (h.mobile) $("offSetup").textContent = "Change headset…"; + $("nowMac").hidden = !!h.mobile || h.computer !== "Mac"; } // Images to the phone (the app offers Save Image to Photos, Files and so on). function blobBase64(blob) { @@ -1513,6 +1565,7 @@ function setOnline(ok, why) { $("offMsg").textContent = why || "Can't reach the Frame"; setConn(false, "Offline"); $("battChip").hidden = true; closeDevPop(); + $("nowState").textContent = "Waiting for the Frame…"; if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); } if (window.connBanner) window.connBanner(); } else if (was === false) { @@ -1615,7 +1668,8 @@ function renderComfort(s) { if (s.id !== comfortSession) { comfortSession = s.id; comfortSeen.clear(); - if (s.options) for (const [key, value] of Object.entries(s.options)) { + // A finished session's settings (a one-minute test, say) mustn't replace the defaults. + if (s.active && s.options) for (const [key, value] of Object.entries(s.options)) { const el = $(key === "minutes" ? "sessionMinutes" : key); if (!el) continue; if (el.type === "checkbox") el.checked = value; else el.value = value; @@ -1641,7 +1695,8 @@ function renderComfort(s) { // Keep only the server's bounded history; a new page seeds it without replay. comfortSeen = new Set((s.events || []).map(e => e.id)); if (latest !== null) $("comfortAnnouncement").textContent = latest; - const history = (s.events || []).slice(-3).map(e => e.message).join(" · "); + const at = t => new Date(t * 1000).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + const history = (s.events || []).slice(-3).map(e => `${at(e.time)} ${e.message}`).join(" · "); if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history; } async function pollComfort() { @@ -1672,6 +1727,13 @@ $("testNotification").onclick = () => act("Test notification", async () => { await localNotification("Comfort notifications are enabled on this device.", true); return {message: "Test notification sent. Check your device's notification settings if it didn't appear."}; }); +// Home's shortcuts to the things people come to do next. +document.querySelectorAll("[data-go]").forEach(b => b.onclick = () => { + const to = b.dataset.go; + if (location.hash === "#" + to) showPage(); else location.hash = to; + if (to === "pad") { if (pad.state === "off" || pad.state === "error") padStart(); $("padType").focus({ preventScroll: true }); } +}); +$("nowSend").onclick = () => $("fileInput").click(); $("castBtn").onclick = () => { setView("headset"); if (!live) toggleLive(true); @@ -1752,6 +1814,8 @@ function render(s) { const svc = [["SteamVR", s.services.steamvr], ["Desktop", s.services.desktop], ["Lepton", s.services.lepton], ["RDP", s.services.rdp]]; + $("nowState").textContent = s.services.steamvr ? "In VR: SteamVR is running." : + "SteamVR isn't running, so there's no headset view until it starts."; $("services").innerHTML = svc.map(([n, on]) => `${esc(n)}`).join(""); if (s.volume && !volTimer) setVolume(Math.round(s.volume.level * 100), s.volume.muted); @@ -3739,8 +3803,10 @@ $("macview").onclick = async e => { // ---- pages: #home, #games, #android, #tools (older section links still work) ---- const PAGES = ["home", "games", "android", "tools"]; -const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games", - display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" }; +const SECTION_PAGE = { view: "home", device: "home", shots: "home", comfort: "home", pad: "home", vrTools: "home", + library: "games", sideloaded: "games", getgames: "games", display: "android", + transfer: "tools", apps: "tools", power: "tools", macview: "tools", media: "tools", panels: "tools", + privacy: "settings", artworkSettings: "settings", assistant: "settings" }; let page = "home"; function showPage() { const id = location.hash.slice(1); @@ -3749,6 +3815,7 @@ function showPage() { page = next; document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page)); document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page)); + $("settingsBtn").classList.toggle("on", page === "settings"); document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`; const section = !PAGES.includes(id) && id && $(id); if (section) section.scrollIntoView(); else scrollToY(0); @@ -3781,6 +3848,8 @@ function padShow(status) { $("padOn").hidden = ready || busy; $("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on"; $("padArea").classList.toggle("off", !ready); + $("padBody").hidden = pad.state === "off"; + $("padIntro").hidden = pad.state !== "off"; $("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame." : !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.") : TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click" @@ -4565,7 +4634,8 @@ function setUpActive() { $("connSetup").onclick = setUpActive; // ---- Devices tab ---- -PAGES.push("devices"); +PAGES.push("devices", "settings"); // Settings has no tab: the gear in the header, or key 6 +$("settingsBtn").onclick = () => { location.hash = "settings"; }; let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection async function loadDevices() { if (!link.live) return; From 9e25ae90254e3f43f3359a99bf380e1ee5dfb39a Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:04:50 +1000 Subject: [PATCH 07/69] Make the Android store look and read like the rest of the app Apps without a banner or screenshot show their icon on a blurred wash of itself, or a VR or app glyph, rather than a giant letter. Every card says whether it will run on the Frame (works, should install, not checked yet, won't run), in place of "Free" on every card. The featured app isn't repeated in the rows below, and no row repeats another. Its buttons stay in the app: Install on the Frame opens the details with the install under way, and Details opens them. It's chosen from apps that can run, with real artwork, VR first. The stray "Browse SideQuest" link goes; the Sources dialog lists SideQuest with a link to its website instead of a switch that did nothing. The "All sources" drop-down goes too, as the Sources dialog already chooses them. The copy is plain and British ("Apps and games for the Frame", no "Find your next favorite"), and Games and Android point at each other. Two functions were each declared twice across the page's scripts, so the later one silently replaced the earlier everywhere: the store's details dialog opened empty (the Devices tab's renderDetail ran instead), and Home's Desktop view never found the headset's panels (the panel switcher's loader ran instead). They're renamed, and a test fails on any top-level name declared twice. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/sidequest.md | 2 + tests/test_page_scripts.py | 22 ++++++ ui/index.html | 137 +++++++++++++++++++++++++------------ 3 files changed, 117 insertions(+), 44 deletions(-) create mode 100644 tests/test_page_scripts.py diff --git a/docs/sidequest.md b/docs/sidequest.md index eba0f02..23311d0 100644 --- a/docs/sidequest.md +++ b/docs/sidequest.md @@ -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; diff --git a/tests/test_page_scripts.py b/tests/test_page_scripts.py new file mode 100644 index 0000000..35ba048 --- /dev/null +++ b/tests/test_page_scripts.py @@ -0,0 +1,22 @@ +"""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 collections +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class PageScripts(unittest.TestCase): + def test_no_top_level_name_is_declared_twice(self): + page = (ROOT / 'ui/index.html').read_text(encoding='utf-8') + names = collections.Counter() + for js in re.findall(r'', page, re.S): + for m in re.finditer(r'^(?:async\s+)?function\s+(\w+)|^(?:const|let|var)\s+(\w+)\s*=', js, re.M): + names[m.group(1) or m.group(2)] += 1 + self.assertEqual({k: n for k, n in names.items() if n > 1}, {}) + + +if __name__ == '__main__': + unittest.main() diff --git a/ui/index.html b/ui/index.html index 59a80c6..112f99c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -569,6 +569,8 @@ .store-chips button[aria-pressed=true] { background:#235779; border-color:#5287a5; color:white; } .store-source-select select { max-width:180px; padding:8px 26px 8px 10px; font:inherit; font-size:12px; border:0; border-radius:5px; color:var(--text); background:#1d2b3b; } .store-notice { color:#a8b5c2; font-size:12px; margin:12px 0; } + .store-heading a, .cross-link a { color:var(--link); } + .cross-link { margin:-6px 0 12px; } body:has(.page.on #appStore:not([hidden])) #offline { padding:10px 16px; background:#ffffff06; box-shadow:inset 3px 0 0 var(--dim); } body:has(.page.on #appStore:not([hidden])) #offline .s { display:none; } .store-result-count { font-size:13px; color:var(--muted); margin-bottom:14px; } @@ -576,14 +578,24 @@ .store-hero { position:relative; display:block; width:100%; height:270px; border:0; padding:0; border-radius:12px; overflow:hidden; text-align:left; margin:0 0 32px; isolation:isolate; } .store-art { position:relative; display:block; overflow:hidden; background:radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%),transparent 70%),linear-gradient(140deg,hsl(var(--hue) 35% 18%),#101827); } .store-art > img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; } - .art-initial { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:70px; font-weight:700; color:#ffffff45; letter-spacing:-4px; } + .store-art > img.art-wash { inset:-24px; width:calc(100% + 48px); height:calc(100% + 48px); z-index:0; filter:blur(26px) saturate(1.4); opacity:.55; } + .store-art > img.art-icon { inset:0; margin:auto; width:64px; height:64px; border-radius:15px; z-index:0; box-shadow:0 10px 28px #0007; } + .detail-banner > img.art-icon, .store-hero .store-art > img.art-icon { width:88px; height:88px; border-radius:20px; } + .store-art .art-glyph { position:absolute; inset:0; display:grid; place-items:center; color:hsl(var(--hue) 45% 72% / .38); } + .store-art .art-glyph svg { width:34%; max-width:84px; height:auto; } + .runs { font-size:11px; font-weight:500; padding:3px 8px; border-radius:10px; white-space:nowrap; } + .runs.works { color:#c6efb0; background:#70bb5426; } .runs.ready { color:#b7d8ec; background:#6ec9ff1a; } + .runs.unknown { color:#a8b5c2; background:#ffffff0d; } .runs.blocked { color:#f1c28a; background:#d9a23a22; } .store-hero > .store-art { position:absolute; inset:0; } .store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; } .hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; } .hero-copy .store-eyebrow { color:#c2e7ff; } .hero-copy strong { color:white; font-size:38px; letter-spacing:-.8px; line-height:1.1; } - .hero-copy > span:not(.store-eyebrow):not(.hero-cta) { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; } - .hero-cta { color:white; display:inline-flex; align-items:center; background:#ffffff20; border:1px solid #ffffff40; border-radius:6px; padding:10px 18px; font-size:13px; margin-top:6px; } + .hero-copy .hero-sum { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; } + .hero-actions { display:flex; gap:10px; margin-top:6px; } + .hero-actions button { height:38px; border-radius:6px; padding:0 18px; } + .hero-details { background:#ffffff20; color:#fff; border:1px solid #ffffff40; } + .store-hero { cursor:pointer; } .store-row { margin-bottom:34px; } .store-row-head { display:flex; align-items:baseline; gap:12px; margin-bottom:14px; } .store-row-head h2 { text-transform:none; letter-spacing:-.3px; font-size:21px; font-weight:600; color:var(--bright); margin:0; } @@ -659,6 +671,7 @@ .source-setting strong { display:block; font-size:14px; color:white; } .source-setting p { color:#a3b4c3; font-size:12px; margin:3px 0; } .source-setting small { color:var(--muted); font-size:10px; } + .source-web { font-size:12px; white-space:nowrap; } .source-toggle { appearance:none; width:38px; height:23px; flex:none; border-radius:15px; background:#465463; position:relative; cursor:pointer; margin:0; } .source-toggle::after { content:""; position:absolute; top:3px; left:3px; width:17px; height:17px; background:#fff; border-radius:50%; transition:transform .15s; } .source-toggle:checked { background:#208dca; } @@ -682,7 +695,7 @@ .store-chips { gap:5px; } .store-chips button { font-size:11px; padding:0 10px; } .store-hero { height:285px; border-radius:9px; margin-bottom:26px; } .hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; } - .hero-copy > span:not(.store-eyebrow):not(.hero-cta) { font-size:13px; max-width:260px; } + .hero-copy .hero-sum { font-size:13px; max-width:260px; } .store-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; } .store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; } .store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; } @@ -710,7 +723,7 @@ @@ -964,6 +977,8 @@

      Get games

      +
      @@ -986,11 +1001,11 @@
      -
      +
      -
      A little more possibility

      Find your next favorite.

      Great games and useful apps. All in one place, all free to explore.

      +

      Apps and games for the Frame

      Free Android apps and VR games from open app stores and developers' own pages, each marked with whether it runs on the Frame. Steam games are in Games.

      -
      +
      @@ -1260,9 +1275,9 @@
      -
      Your app shelves

      A world of apps.

      Choose where you discover games and apps. We bring them together for you.

      +

      Sources

      Where Discover looks for apps. Turn a source off to hide its apps.

      -

      Add a source

      Have a favorite F-Droid repository? Add its address to browse its apps here.

      Verify this source (optional)
      +

      Add a source

      Add an F-Droid repository by its address to browse its apps here.

      Verify this source (optional)

      @@ -3158,46 +3173,80 @@ loadOwned(); const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() }; const storeName = s => String(s || "").replace(/ \(demo\)$/i, ""); const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360; -const storeInitial = name => [...String(name || "?")][0].toUpperCase(); +const storeInitial = name => [...String(name || "?")][0].toUpperCase(); // the small icon's stand-in const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/ /g, " "); const storeOpenSource = o => o.open_source === true || /^(MIT|Apache|[AL]?GPL|BSD|MPL|ISC|CC0|Unlicense)/i.test(o.license || ""); function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } } function storeImage(path, alt, eager=false) { return /^\/source-image\/[\w-]+$/.test(path || "") ? `${esc(alt)}` : ""; } +// Artwork: the banner or first screenshot. Under it (and instead of it, when there's none, or it +// doesn't load) the app's icon on a blurred wash of itself, or failing that a VR or app glyph. +const GLYPH_VR = ``; +const GLYPH_APP = ``; function storeArt(o, cls="", eager=false, path=null, alt=null) { const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0]; - return `${storeImage(image, alt || `${o.name} artwork`, eager)}`; + const icon = !path && /^\/source-image\/[\w-]+$/.test(o.artwork?.icon || "") ? o.artwork.icon : ""; + const under = path ? "" : icon + ? `` + : `${o.vr ? GLYPH_VR : GLYPH_APP}`; + return `${under}${storeImage(image, alt || `${o.name} artwork`, eager)}`; } function storeIcon(o) { return ``; } -document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true); +document.addEventListener("error", e => { + if (!e.target.matches?.(".store-art img, .store-icon img")) return; + const art = e.target.closest(".store-art"); + e.target.remove(); + // Nothing left to show: the glyph rather than an empty wash. + if (art && !art.querySelector("img") && !art.querySelector(".art-glyph")) art.insertAdjacentHTML("afterbegin", `${GLYPH_APP}`); +}, true); function sourceMark(s) { const name=storeName(s); return ``; } -function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); } +function sourceDescription(s) { + const kinds = {fdroid:"Free and open-source Android apps", itch:"Games from independent developers", github:"Releases straight from the developers", + sidequest:"VR games and apps. Its terms don't allow searching it from here, so browse it on its website."}; + return s.description || kinds[s.kind] || "Apps from this repository"; +} async function loadSources() { try { const {sources} = await api("/api/sources"); sourceState.sources = sources; - $("sourceFilter").innerHTML = `` + sources.filter(s=>s.enabled).map(s=>``).join(""); - $("sourceFilter").value = sourceState.source; - $("sourcesList").innerHTML = sources.map(s=>`
      ${sourceMark(s.name)}
      ${esc(storeName(s.name))}

      ${esc(sourceDescription(s))}

      ${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}${s.builtin === false ? `` : ""}
      `).join("") || `

      Your sources will appear here when they are available.

      `; + $("sourcesList").innerHTML = sources.map(s=>`
      ${sourceMark(s.name)}
      ${esc(storeName(s.name))}

      ${esc(sourceDescription(s))}

      ${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}${s.builtin === false ? `` : ""}
      ${s.page_only ? `Website ↗` : ``}
      `).join("") || `

      Your sources will appear here when they are available.

      `; } catch (e) { $("sourceError").textContent = e.message; } } +// Will it run? Short enough for a card; the full sentence is in its tooltip and the details. +const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["Won't run", "blocked"] }; +function storeRuns(o) { + const [text, cls] = RUNS[o.verdict?.tone] || RUNS.unknown; + return `${text}`; +} +const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app"; function storeCard(app) { const i=sourceState.apps.indexOf(app), o=app.offers[0]; - return ``; + return ``; } -function storeRow(title, subtitle, apps) { return apps.length ? `

      ${esc(title)}

      ${esc(subtitle)}

      ${apps.slice(0,5).map(storeCard).join("")}
      ` : ""; } -function storeEmpty() { - return `

      A little further to explore.

      No apps found this time. Try a different name, clear your filters, or explore another source.

      `; +function storeRow(title, subtitle, apps) { return apps.length ? `

      ${esc(title)}

      ${subtitle ? `

      ${esc(subtitle)}

      ` : ""}
      ${apps.slice(0,5).map(storeCard).join("")}
      ` : ""; } +function storeEmpty(message) { + return `

      ${message ? "Couldn't load apps" : "No apps found"}

      ${message ? esc(message) : "Try another name, or clear the filters."}

      `; } function renderStore() { const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable && !sourceState.source; $("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`; if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; } if (!browsing) { $("searchGrid").innerHTML=`
      ${apps.map(storeCard).join("")}
      `; return; } - const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0]; - const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works')); - const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked')); - const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0)); - const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||''))); - $("searchGrid").innerHTML=`` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent); + // The featured app: something that runs, with real artwork, and preferably VR. It isn't repeated below. + const runs=a=>a.offers[0].verdict?.tone!=='blocked', art=a=>a.offers[0].artwork?.banner; + const featured=apps.find(a=>a.offers[0].vr && art(a) && runs(a)) || apps.find(a=>art(a) && runs(a)) || apps.find(runs) || apps[0], o=featured.offers[0]; + const rest=apps.filter(a=>a!==featured); + const tested=rest.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works')); + const vr=tested.length ? tested : rest.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked')); + // Rows don't repeat each other either: each takes the next apps not shown yet. + const shown=new Set(), take=list=>{ const out=list.filter(a=>!shown.has(a)).slice(0,5); out.forEach(a=>shown.add(a)); return out; }; + const vrRow=take(vr); + const likely={works:0,ready:1,unknown:2,blocked:3}, rank=a=>likely[a.offers[0].verdict?.tone] ?? 2; + const popular=take([...rest].sort((a,b)=>rank(a)-rank(b) || (b.offers[0].popularity||0)-(a.offers[0].popularity||0))); + const recent=take(rest.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||'')))); + const canInstall=o.downloadable && runs(featured); + $("searchGrid").innerHTML=`
      ${storeArt(o,"",true)}
      Featured${o.vr ? " in VR" : ""}${esc(featured.name)}${esc(storeText(featured.summary))}${storeRuns(o)}${canInstall ? `` : ""}
      ` + + storeRow(tested.length ? "VR that works on the Frame" : "VR games and experiences", tested.length ? "Checked on a Frame" : "Made for headsets; most aren't checked on the Frame yet", vrRow) + + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular" : "More to try", "", popular) + + storeRow("Recently updated", "New versions from their developers", recent); } function storeChips() { $("searchFilters").querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",b.dataset.filter==='all' ? sourceState.vr===null && !sourceState.installable : b.dataset.filter==='installable' ? sourceState.installable : sourceState.vr===(b.dataset.filter==='vr'))); } async function searchSources(quiet) { @@ -3217,20 +3266,20 @@ async function searchSources(quiet) { result.sources.filter(s=>s.status==='limited').forEach(s=>notes.push(`${s.error}.`)); const stale=result.sources.filter(s=>s.stale).map(s=>storeName(s.name)); if(stale.length)notes.push(`Showing saved listings from ${stale.join(" and ")} while ${stale.length===1 ? "it refreshes" : "they refresh"}.`); - $("sourceStatus").innerHTML=notes.map(esc).join(" ")+(result.elsewhere||[]).map(x=>` Browse ${esc(storeName(x.name))} ↗`).join(""); - $("sourceStatus").hidden=!notes.length && !(result.elsewhere||[]).length; + $("sourceStatus").textContent=notes.join(" "); + $("sourceStatus").hidden=!notes.length; clearTimeout(sourceState.retry); if(loading.length)sourceState.retry=setTimeout(()=>{ if(request===sourceState.request)searchSources(true); },6000); renderStore();await loadSources(); - } catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML=storeEmpty();$("sourceStatus").hidden=false;$("sourceStatus").textContent="We couldn't reach the app shelves. Try again in a moment."; } } + } catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML=storeEmpty("Couldn't reach the app sources. Try again in a moment.");$("sourceStatus").hidden=true; } } finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); } } function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; } -function detailBadges(o) { return `${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}${o.verdict?.tone==='works' ? '✓ Works on Frame' : ''}Free${storeOpenSource(o) ? 'Open source' : ''}`; } -function renderDetail() { +function detailBadges(o) { return `${storeKind(o)}${storeRuns(o)}Free${storeOpenSource(o) ? 'Open source' : ''}`; } +function renderAppDetail() { const restoreOfferFocus=document.activeElement?.name==='storeOffer'; const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page); - $("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`
      ${storeIcon(o)}

      ${esc(o.name)}

      ${esc(o.developer || o.author || 'From '+storeName(o.source_name))}

      ${detailBadges(o)}
      ${images.length ? `` : ''}

      About this app

      ${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
      ${page ? `

      Meet the creators ↗

      ` : ''}
      Technical details
      Package
      ${esc(o.package||"Unknown")}
      Version
      ${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})
      Android API
      ${esc(o.min_sdk??"Unknown")}
      Architecture
      ${esc(o.abis?.join(', ')||"Unknown / no native code")}
      Updated
      ${esc(o.updated||"Unknown")}
      Compatibility
      ${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}
      `; + $("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`
      ${storeIcon(o)}

      ${esc(o.name)}

      ${esc(o.developer || o.author || 'From '+storeName(o.source_name))}

      ${detailBadges(o)}
      ${images.length ? `` : ''}

      About this app

      ${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
      ${page ? `

      The developer's page ↗

      ` : ''}
      Technical details
      Package
      ${esc(o.package||"Unknown")}
      Version
      ${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})
      Android API
      ${esc(o.min_sdk??"Unknown")}
      Architecture
      ${esc(o.abis?.join(', ')||"Unknown / no native code")}
      Updated
      ${esc(o.updated||"Unknown")}
      Compatibility
      ${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}
      `; updateInstall(); if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus(); } @@ -3238,16 +3287,17 @@ function updateInstall() { const o=currentOffer();if(!o || !$("detailInstall"))return; const job=sourceState.jobs.get(`${o.source}:${o.id}`), blocked=o.verdict?.tone==='blocked', page=sourcePage(o.page), b=$("detailInstall"); b.disabled=!!(job && !job.done) || !!job?.addingData || (blocked && !job?.result) || (!o.downloadable && !page); - b.textContent=job?.result?.game_data ? (job.addingData ? 'Adding game data…' : 'Add game data') : job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on Frame') : 'Get from the creator ↗'; + b.textContent=job?.result?.game_data ? (job.addingData ? 'Adding game data…' : 'Add game data') : job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on the Frame') : 'Get from the creator ↗'; $("detailProgress").hidden=!job || job.done; if(job?.percent!=null)$("detailProgress").value=job.percent;else $("detailProgress").removeAttribute("value"); - $("detailInstallNote").textContent=job?.result?.game_data ? (job.dataError || 'Installed. It also needs its game data: open it once on the Frame, then choose Add game data.') : job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Keep exploring. We’ll take care of the installation.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.'); + $("detailInstallNote").textContent=job?.result?.game_data ? (job.dataError || 'Installed. It also needs its game data: open it once on the Frame, then choose Add game data.') : job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Installing. You can close this and keep browsing.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.'); $("detailOffers").querySelectorAll('input').forEach(input=>input.disabled=!!job&&!job.done); } -async function openStoreApp(index) { +async function openStoreApp(index, install=false) { const app=sourceState.apps[index];if(!app)return; sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0}; - renderDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0; + renderAppDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0; + if(install)installStoreApp(); // the hero's Install: the details show its progress await loadOfferDetails(); } async function loadOfferDetails() { @@ -3256,7 +3306,7 @@ async function loadOfferDetails() { const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id})); if(request!==sourceState.detailRequest || d!==sourceState.detail)return; const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]}; - d.app.offers[d.choice]={...o,...detail,artwork};renderDetail(); + d.app.offers[d.choice]={...o,...detail,artwork};renderAppDetail(); } catch(e) { if(d===sourceState.detail)$("detailInstallNote").textContent="Some app details couldn't be refreshed. You can still view this listing."; } } async function installStoreApp() { @@ -3284,11 +3334,10 @@ async function changeSource(body) { let storeSearchTimer; $("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);}; $("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer);searchSources();}; -$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();}; $("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();}; -$("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}}; +$("searchGrid").onclick=e=>{const h=e.target.closest('[data-hero-install]');if(h){openStoreApp(+h.dataset.heroInstall,true);return;}const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}}; $("detailBody").onclick=e=>{if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});}; -$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderDetail();loadOfferDetails();}}; +$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}}; $("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();}; $("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});}; $("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});}; @@ -3707,7 +3756,7 @@ $("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/ap // ---- Panel switcher: our UI over SteamVR's panel API ---- let panelSeq = 0; -async function loadPanels() { +async function loadPanelSwitcher() { const seq = ++panelSeq; try { const s = await api("/api/panels", {action: "list"}); @@ -3718,7 +3767,7 @@ async function loadPanels() { : '
      No open panels.
      '; } catch (e) { if (seq === panelSeq) failed($("panelList"), e); } } -$("panelsRefresh").onclick = loadPanels; +$("panelsRefresh").onclick = loadPanelSwitcher; $("panelsOpen").onclick = e => act("Open panel switcher in headset", () => api("/api/panels", {action: "open"}), e.currentTarget); $("panelList").onclick = e => { @@ -3819,7 +3868,7 @@ function showPage() { document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`; const section = !PAGES.includes(id) && id && $(id); if (section) section.scrollIntoView(); else scrollToY(0); - if (page === "tools") { loadMacView(); loadPanels(); } + if (page === "tools") { loadMacView(); loadPanelSwitcher(); } } window.addEventListener("hashchange", showPage); // While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS). From 8051687e1eccb919f1832c8cdb6f5b66cf5aa191 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:18:58 +1000 Subject: [PATCH 08/69] CI: stop recurring false failures on main and pull requests - checks: the runner's packages.microsoft.com apt source now 403s, failing `apt-get update` and with it "Install zsh"; tolerate the refresh error (Ubuntu's lists still update and the install still fails if it can't). - e2e: a failed quiet image build is rebuilt with the full log, but the script exited 2 even when that rebuild succeeded; only fail if it fails. - ArtworkTests: a timed-out lookup gives its resolver slot back from its own thread, which a busy macOS runner may schedule after the next test starts. Wait for every slot before each test, and always return slots a test took, so one late thread can't cascade into three failures. - release: tagged builds no longer re-upload the installers as workflow artifacts (they are attached to the release; the artifact finalize timed out on v0.3.0). Pull requests build installers only when the Electron app, the macOS helper it compiles, or the workflow changes. - checks and release: a newer push to a pull request cancels the older run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/checks.yml | 13 ++++++++-- .github/workflows/release.yml | 14 +++++++++-- scripts/e2e.sh | 3 ++- tests/test_frame_android_library.py | 37 ++++++++++++++++++++++------- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f131fe4..b7624f0 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: +# A newer push to the same pull request supersedes a run still in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: checks: runs-on: ubuntu-latest @@ -17,7 +22,9 @@ jobs: with: node-version: "24" - name: Install zsh - run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh + # The runner image's third-party apt sources (packages.microsoft.com) sometimes + # fail to refresh; Ubuntu's own lists still update, and the install fails if not. + run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh - name: Script syntax run: | sh -n ui/local-bin/ssh @@ -84,6 +91,8 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install zsh - run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh + # The runner image's third-party apt sources (packages.microsoft.com) sometimes + # fail to refresh; Ubuntu's own lists still update, and the install fails if not. + run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh - name: End-to-end tests run: scripts/e2e.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04cfeaf..dcb37fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,14 +2,22 @@ name: release # Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches # the installers to that tag's GitHub release (created as a draft if missing). -# Pull requests that touch the app build the same installers as artifacts. +# Pull requests that change how the installers are built (the Electron app, the +# macOS helper it compiles, or this workflow) build the same installers as +# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and +# are tested by the checks workflow; run this one by hand to package them early. on: push: tags: ["v*"] pull_request: - paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"] + paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"] workflow_dispatch: +# A newer push to the same pull request supersedes a build still in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: write @@ -49,7 +57,9 @@ jobs: tag="${GITHUB_REF_NAME}" gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes "" gh release upload "$tag" ${{ matrix.files }} --clobber + # Tagged builds are attached to the release above; only other builds need artifacts. - uses: actions/upload-artifact@v4 + if: ${{ !startsWith(github.ref, 'refs/tags/') }} with: name: frame-control-${{ matrix.os }} path: | diff --git a/scripts/e2e.sh b/scripts/e2e.sh index c8d4ef0..75ad349 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -23,7 +23,8 @@ started=$SECONDS print "==> Building fakeframe-frame (from $base) and fakeframe-host" # Quiet when it works; if a build fails, build again with the full log so CI shows why. -build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } } +# That second build also rides out a flaky package mirror: only its failure is fatal. +build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 } build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index cdf51c0..537e46a 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -120,7 +120,27 @@ class LauncherTests(unittest.TestCase): FIXTURES = ROOT / 'tests/fixtures/library' +def wait_for_idle_resolvers(timeout=10): + # Name lookups that outlast their deadline give their slot back from their own thread, + # which a busy runner may not schedule before the next test; wait until all are back. + from apk_sources import _images + held = [] + try: + end = time.monotonic() + timeout + while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())): + held.append(1) + if len(held) == 4: + return + raise AssertionError('artwork name lookups from an earlier test are still running') + finally: + for _ in held: + _images._resolvers.release() + + class ArtworkTests(unittest.TestCase): + def setUp(self): + wait_for_idle_resolvers() + def test_source_inputs_and_url(self): from apk_sources import _images data = (FIXTURES / 'icon.png').read_bytes() @@ -231,10 +251,7 @@ class ArtworkTests(unittest.TestCase): self.assertEqual(sum('Too many' in e for e in errors), 2) finally: gate.set() - deadline = time.monotonic() + 5 - while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False): - time.sleep(0.01) - _images._resolvers.release() # the stuck lookups finished and gave their slots back + wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back def test_resolver_slot_released_when_thread_cannot_start(self): from apk_sources import _images @@ -242,10 +259,14 @@ class ArtworkTests(unittest.TestCase): for _ in range(6): with self.assertRaises(RuntimeError): _images.get('https://example.org/a.png', deadline=time.monotonic() + 1) - for _ in range(4): # every slot came back - self.assertTrue(_images._resolvers.acquire(blocking=False)) - for _ in range(4): - _images._resolvers.release() + held = 0 + try: + while held < 4 and _images._resolvers.acquire(blocking=False): + held += 1 + self.assertEqual(held, 4) # every slot came back + finally: + for _ in range(held): # even on failure, so later tests don't inherit the leak + _images._resolvers.release() def test_deadline_covers_name_resolution(self): import threading From d6fb5e917a48af00f048ec582ae3fb6843ea974e Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:18:59 +1000 Subject: [PATCH 09/69] UEVR helper tests: import and skip cleanly on Windows frame_mods runs on the Frame, but the server tests import it on every desktop OS; the module-level `import fcntl` failed test discovery on Windows. Import it where the lock is taken, as frame_panels and frame_vr do. Skip the Frame-side helper tests on Windows, as test_input does for the input agent: they rely on /proc, flock and POSIX prefix paths. The server routes and the usage-error check still run there. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_mods.py | 5 ++++- ui/frame_mods.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_mods.py b/tests/test_mods.py index 3a656d9..fc76fec 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -36,6 +36,7 @@ def zip_bytes(files, links=()): 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.""" @@ -230,9 +231,10 @@ class Downloads(FakeLibrary): 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: - frame_mods.fcntl.flock(held, frame_mods.fcntl.LOCK_EX) + 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"]) @@ -294,6 +296,7 @@ class Start(FakeLibrary): 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( diff --git a/ui/frame_mods.py b/ui/frame_mods.py index 482b2ea..4f42f6f 100644 --- a/ui/frame_mods.py +++ b/ui/frame_mods.py @@ -22,7 +22,6 @@ from python.org, each checked against a pinned hash. Files live in /drive_c/frame-control, never in the game's own folder, and a receipt records what to remove. """ -import fcntl import hashlib import json import os @@ -511,6 +510,7 @@ def main(argv): 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: From 3f20e5f9d343223bba994876d0461821e590c159 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:29:56 +1000 Subject: [PATCH 10/69] Windows test VM: a driver script and how to set one up scripts/windows-vm.sh drives a dockur/windows Windows 11 VM from the Mac over ssh -J: start/stop, PowerShell, copy files in, screenshots via QEMU's screendump, clicks and scrolls replayed by a scheduled task in the signed-in session, QEMU key names, and a tagged, removable headset key for tests against a real Frame. docs/testing.md explains setting it up (Windows from Microsoft, loopback-only ports) and the pitfalls found reproducing the Windows "RDP not working" report. docs/streaming.md records what Remote Desktop from Windows does against the Frame's xrdp (verified 2026-09-30). Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- docs/streaming.md | 9 +++ docs/testing.md | 72 ++++++++++++++++++++++++ scripts/windows-vm.sh | 128 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100755 scripts/windows-vm.sh diff --git a/README.md b/README.md index fb40d32..d42fb43 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ Frame's software fits together, all checked against a real headset and labelled | [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 | | [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel | -| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test | +| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM | | [Open questions](docs/open-questions.md) | What's still unchecked |
      diff --git a/docs/streaming.md b/docs/streaming.md index 773e698..b810934 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -21,6 +21,15 @@ The confidence labels are the same as in [ssh.md](ssh.md). | **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. | | VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. | +**RDP from Windows, verified 2026-09-30:** Windows 11 25H2's Remote Desktop +(`mstsc`) against BUILD_ID 20260925.6191901. xrdp picks TLS, not NLA, so +Remote Desktop never asks for a user or password. It warns that the certificate +(`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to +frame" box with the username blank. Any user but `steamos` gets "User does not +exist, or could not be authenticated". Signing in as `steamos` with the +Developer Mode password is still unchecked: the Frame's log showed no +successful xrdp login up to that date. + **Recommendation for A:** start with Steam Link for macOS, because Valve documents it. Use Windows App (RDP) when you want a proper Linux desktop on the Mac with keyboard, mouse, and clipboard. diff --git a/docs/testing.md b/docs/testing.md index 82a1826..61c4168 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,6 +8,7 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste | Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked | | Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame | | Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID | +| [Windows test VM](#windows-test-vm) | `scripts/windows-vm.sh` | A Linux machine with KVM and Docker | The Windows build on a real Windows desktop, against a real Frame when needed | ## Unit tests @@ -163,6 +164,77 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had accepted. The fake now refuses them the same way, and Frame Control makes ids Steam accepts. +## Windows test VM + +The unit tests run on Windows in CI, but the app itself doesn't. Anything that +depends on the Windows desktop (Remote Desktop, the installer, the bundled +Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a +[dockur/windows](https://github.com/dockur/windows) container on a Linux machine +with KVM, driven from the Mac with `scripts/windows-vm.sh`. + +Setting it up, once, on the Linux machine: + +- **Windows comes from Microsoft.** The container downloads the Windows 11 + image from Microsoft on first start. Windows runs unactivated, which is fine + for testing. Don't use activation workarounds or third-party Windows images. +- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH), + `127.0.0.1:8006:8006` (the web console) and, if you need it, + `127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use + `restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is + testing, and a `docker stop` shuts Windows down cleanly. +- **Give it SSH on first sign-in.** The container runs `/oem/install.bat` + once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell + as its default shell, and put a dedicated public key (for example + `~/.ssh/id_ed25519_winvm` on the Mac) in + `C:\ProgramData\ssh\administrators_authorized_keys`. +- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app + and the tests. + +Using it, with `WINVM_HOST` set to the Linux machine's ssh alias: + +```sh +scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes) +scripts/windows-vm.sh put Frame-Control-Setup-x64.exe +scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait' +scripts/windows-vm.sh shot screen.png # what's on its screen +scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot +scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names +scripts/windows-vm.sh down # shut Windows down +``` + +- **Clicks go through a scheduled task.** Commands over SSH run in a + session with no desktop, so `click` and `scroll` write the position to a + file, and a scheduled task running as the signed-in user replays it. The + VM's screen must be signed in; it is after `up`. QEMU's own `mouse_move` is + relative and drifts, so the script doesn't use it. +- **Screenshots may not show the pointer.** Check the result of a click (a + menu that opens, a button that changes) rather than the pointer's position. +- **Windows' `ssh` waits for stdin.** The script closes it for every command. + Do the same if you run `ssh` in the VM by hand. +- **Starting the app.** Run Frame Control in the signed-in session, not over + SSH. Use its Start-menu shortcut via `click`, or a scheduled task like the + one `click` uses. + +**Testing against a real Frame.** The VM reaches the headset on the LAN like +any other computer. Set up Frame Control in the VM once, then: + +```sh +scripts/windows-vm.sh frame-key add # let the VM's key into the headset +# ... test ... +scripts/windows-vm.sh frame-key remove # and take it out again +``` + +`frame-key` tags the key `windows-vm-test` in the headset's +`authorized_keys`, and `remove` deletes only that line. Follow the shared-device +procedure in [Headset smoke test](#headset-smoke-test) before installing or +launching anything on the headset. + +**Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on +BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0, +connect it to the Frame and reproduce a Remote Desktop report from +screenshots. It also took Windows-side logs, and `frame-key` left +`authorized_keys` byte-for-byte as it was. + ## Owned media player `tests/test_media.py` covers layout evidence and overrides, OU eye ordering, diff --git a/scripts/windows-vm.sh b/scripts/windows-vm.sh new file mode 100755 index 0000000..441504d --- /dev/null +++ b/scripts/windows-vm.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env zsh +# Mac or Linux: drive a Windows 11 test VM for Frame Control's Windows build. +# The VM is a dockur/windows container on another machine; this reaches it over +# SSH through that machine. See docs/testing.md#windows-test-vm. +# +# Usage: scripts/windows-vm.sh [args] +# up | down | status start it (waits for SSH), shut Windows down cleanly, show state +# ps '' run PowerShell as the VM's user +# put [] copy a file in (default: the user's Downloads) +# shot save the VM's screen +# click click screen pixel x,y in the signed-in session +# scroll turn the wheel n notches at x,y (negative scrolls down) +# keys ... type QEMU key names: a, shift-a, ret, tab, esc, spc ... +# frame-key add|remove let the VM's Frame Control key into the headset (`frame` +# alias) for a test run, then take it out again +# +# Set WINVM_HOST to the ssh alias of the machine running the container. Optional: +# WINVM_CONTAINER (frame-winvm), WINVM_USER (frame), WINVM_PORT (2222: the VM's +# sshd, published on that machine's loopback), WINVM_KEY (~/.ssh/id_ed25519_winvm). +set -euo pipefail +setopt extendedglob + +host=${WINVM_HOST:?set WINVM_HOST to the ssh alias of the machine running the VM} +ctr=${WINVM_CONTAINER:-frame-winvm} +user=${WINVM_USER:-frame} +port=${WINVM_PORT:-2222} +key=${WINVM_KEY:-$HOME/.ssh/id_ed25519_winvm} +opts=(-o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new + -o UserKnownHostsFile=${TMPDIR:-/tmp}/windows-vm-known_hosts -i $key -J $host) +TAG=windows-vm-test # comment on the VM's key in the headset's authorized_keys + +die() { print -u2 "windows-vm: $*"; exit 1 } +int() { [[ $1 == (-|)<-> ]] || die "not a whole number: $1" } + +# Windows' OpenSSH waits for stdin to close, so it always gets /dev/null. The +# script travels UTF-16 base64-encoded, so no quoting survives two shells. +vm_ps() { + local b64=$(print -rn -- "\$ProgressPreference = 'SilentlyContinue'"$'\n'"$1" | + iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n') + ssh $opts -p $port $user@127.0.0.1 "powershell -NoProfile -NonInteractive -EncodedCommand $b64" /dev/null } + +# Input has to come from the signed-in desktop session, not SSH's session 0, so +# a scheduled task running as the user replays one click or wheel turn written +# to input.txt, then deletes the file to say it's done. +INPUT_PS1='$a = (Get-Content "$PSScriptRoot\input.txt").Trim() -split "\s+" +Add-Type -Namespace WinVm -Name Input -MemberDefinition @" +[DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); +[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); +[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, System.IntPtr extra); +"@ +[WinVm.Input]::SetProcessDPIAware() | Out-Null +[WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1]) | Out-Null +Start-Sleep -Milliseconds 150 +if ($a[2] -eq "click") { + [WinVm.Input]::mouse_event(0x2, 0, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 60 + [WinVm.Input]::mouse_event(0x4, 0, 0, 0, [IntPtr]::Zero) +} else { [WinVm.Input]::mouse_event(0x800, 0, 0, 120 * [int]$a[3], [IntPtr]::Zero) } +Remove-Item "$PSScriptRoot\input.txt"' + +pointer() { # x y click|wheel [notches] + local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n') + vm_ps '$d = Join-Path $env:LOCALAPPDATA "windows-vm" +New-Item -ItemType Directory -Force $d | Out-Null +[IO.File]::WriteAllText("$d\input.ps1", [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'$b64'"))) +$act = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$d\input.ps1`"" +$who = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive +Register-ScheduledTask -TaskName WindowsVmInput -Action $act -Principal $who -Force | Out-Null +Set-Content "$d\input.txt" "'"$*"'" +Start-ScheduledTask -TaskName WindowsVmInput +foreach ($i in 1..50) { if (-not (Test-Path "$d\input.txt")) { exit 0 }; Start-Sleep -Milliseconds 200 } +Remove-Item "$d\input.txt" -ErrorAction SilentlyContinue +Write-Error "no input after 10 s: is $env:USERNAME signed in on the VM screen?"; exit 1' +} + +(( $# )) || die "usage: see the top of $0" +cmd=$1; shift +case $cmd in + up) + ssh $host "docker start $ctr" >/dev/null + for i in {1..60}; do + vm_ps 'exit 0' 2>/dev/null && { print "up"; exit 0 } + sleep 5 + done + die "Windows didn't answer on SSH within 5 minutes" ;; + down) # the container turns SIGTERM into an ACPI shutdown and waits for Windows + ssh $host "docker stop -t 150 $ctr" >/dev/null && print "down" ;; + status) ssh $host "docker ps -a --filter 'name=^$ctr\$' --format '{{.Names}}: {{.Status}}'" ;; + ps) (( $# == 1 )) || die "usage: ps ''"; vm_ps "$1" ;; + put) + [[ -f ${1:-} ]] || die "usage: put []" + scp -q $opts -P $port $1 "$user@127.0.0.1:${2:-C:/Users/$user/Downloads}/${1:t}" " + print "screendump /tmp/windows-vm-shot.ppm" | monitor + sleep 1 + ssh $host "docker exec $ctr sh -c 'cat /tmp/windows-vm-shot.ppm && rm /tmp/windows-vm-shot.ppm'" | python3 -c ' +import re, struct, sys, zlib +d = sys.stdin.buffer.read() +m = re.match(rb"P6\s+(\d+)\s+(\d+)\s+255\s", d) or sys.exit("windows-vm: no screen dump") +w, h = int(m[1]), int(m[2]); px = d[m.end():] +raw = b"".join(b"\0" + px[y * w * 3:(y + 1) * w * 3] for y in range(h)) +chunk = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b)) +open(sys.argv[1], "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")) +' $1 + print $1 ;; + click) (( $# == 2 )) || die "usage: click "; int $1; int $2; pointer $1 $2 click ;; + scroll) (( $# == 3 )) || die "usage: scroll "; int $1; int $2; int $3; pointer $1 $2 wheel $3 ;; + keys) + (( $# )) || die "usage: keys ..." + for k; do [[ $k == [a-z0-9_.,/=-]## ]] || die "not a QEMU key name: $k"; done + for k; do print "sendkey $k"; done | monitor ;; + frame-key) + pub=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r') + pub=${${(z)pub}[1,2]} + [[ $pub == ssh-ed25519\ * ]] || die "the VM has no Frame Control key yet: run Set Up Connection in the app first" + case ${1:-} in + add) ssh frame "grep -qxF '$pub $TAG' ~/.ssh/authorized_keys || echo '$pub $TAG' >> ~/.ssh/authorized_keys" \$f.tmp; chmod 600 \$f.tmp; mv \$f.tmp \$f" Date: Wed, 30 Sep 2026 20:24:48 +1000 Subject: [PATCH 11/69] Docs: signing in over RDP as steamos reaches a Plasma desktop (verified) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/streaming.md | 9 +++++++-- docs/testing.md | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index b810934..48290f4 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -27,8 +27,13 @@ Remote Desktop never asks for a user or password. It warns that the certificate (`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to frame" box with the username blank. Any user but `steamos` gets "User does not exist, or could not be authenticated". Signing in as `steamos` with the -Developer Mode password is still unchecked: the Frame's log showed no -successful xrdp login up to that date. +Developer Mode password opens a Plasma (X11) desktop within about 6 seconds. +It's a new session on display `:10`, separate from what the headset shows, +and it uses about 1.3 GB of the Frame's memory. Closing Remote Desktop leaves +it running, and the next sign-in reconnects to it. To end it, find its +session with `loginctl list-sessions` over SSH (its leader is `xrdp-sesexec`) +and run `loginctl terminate-session `; the headset's own session keeps +running. **Recommendation for A:** start with Steam Link for macOS, because Valve documents it. Use Windows App (RDP) when you want a proper Linux desktop on the diff --git a/docs/testing.md b/docs/testing.md index 61c4168..5b120a7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -231,9 +231,9 @@ launching anything on the headset. **Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0, -connect it to the Frame and reproduce a Remote Desktop report from -screenshots. It also took Windows-side logs, and `frame-key` left -`authorized_keys` byte-for-byte as it was. +connect it to the Frame and follow Remote Desktop through to the Frame's +desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)). +`frame-key` left `authorized_keys` byte-for-byte as it was. ## Owned media player From 12aaec7888e15c4e2f43418b8367692002d2b61d Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:31:02 +1000 Subject: [PATCH 12/69] Keep tool headings on one line and label the battery temperature Mac in the headset's picture quality moves into the panel with a label, and the panel switcher's Open in headset button sits under its list, so neither heading wraps in a narrow column. Headings line up across panels whether or not they hold a button. An odd last figure in a stats grid spans the row instead of leaving a hole. The battery menu says which temperature is the battery's. Co-Authored-By: Claude Opus 5.5 (1M context) --- ui/index.html | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ui/index.html b/ui/index.html index 09870da..85a0ff0 100644 --- a/ui/index.html +++ b/ui/index.html @@ -161,6 +161,7 @@ border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; } /* Buttons in a heading row wrap onto a line of their own rather than squeeze the heading. */ .shelf-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 14px; } + .panel > .shelf-head:first-child h2 { line-height: 28px; } /* as tall as a button, so headings line up across panels */ .shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); white-space: nowrap; } .shelf-head .count { color: var(--muted); font-size: 13px; } .shelf-head .spacer { flex: 1; } @@ -252,6 +253,7 @@ .batt .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; } button.chip { height: 32px; padding: 0 12px; font-size: 13px; } .stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; } + .stats > .stat:last-child:nth-child(odd) { grid-column: 1 / -1; } .stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; } .stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; } .stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums; @@ -417,7 +419,6 @@ .game-card .bar > i { display: block; height: 100%; background: var(--action); } .f-0 { color: var(--muted); } /* ---- Android display ---- */ - #mvQuality { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 4px 6px; font: inherit; font-size: 12px; } .mv-windows { max-height: 420px; overflow-y: auto; } .mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; } .mv-perm .row { margin-top: 8px; } @@ -1102,22 +1103,23 @@

      Panel switcher

      - -
      +
      Refresh to see open panels.
      +
      Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard. Saved spatial layouts are not available yet.