mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64f46f2930 |
No files matched your search
@@ -35,8 +35,8 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**👓 Headset view and Desktop**<br>
|
||||
Live video of what the lenses show, or of the app panel in use, flat and still however the wearer looks around. Turn on Control and tap or click right on it to use the Frame from your phone or computer.
|
||||
**👓 Headset view**<br>
|
||||
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -99,9 +99,7 @@ Frame Control worker into your headset user account.
|
||||
|
||||
For the other features, nothing is installed on the Frame: the app uses what SteamOS
|
||||
already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
`~/devkit-utils`, as Valve's Devkit Client does). The optional
|
||||
[performance HUD](docs/vr-utilities.md) copies our own Python helpers into
|
||||
`~/.local/share/frame-control/vr/`. [How each feature works](docs/frame-control.md).
|
||||
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
|
||||
|
||||
## Install
|
||||
|
||||
@@ -215,7 +213,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [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 |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"tested_head": "be1ab129c9b8663be8d4cd9d5aea0a4c587a16ba",
|
||||
"date": "2026-09-29",
|
||||
"steam_os_build": "20260925.6191901",
|
||||
"battery_percent_start": 100,
|
||||
"battery_percent_end": 100,
|
||||
"activity_level": 3,
|
||||
"warning_to_home_seconds": 60.76079535484314,
|
||||
"after": {
|
||||
"path": "/routes/library/home",
|
||||
"visible": true,
|
||||
"apps": []
|
||||
},
|
||||
"session": {
|
||||
"id": "2f03a8c2eb88482ca28b10246ace496b",
|
||||
"boot": "6c5ddc81-747f-44e5-b8e0-00e0f13fd08e",
|
||||
"active": false,
|
||||
"options": {
|
||||
"minutes": 1,
|
||||
"breakMinutes": 1,
|
||||
"stillMinutes": 1,
|
||||
"batteryAlert": true,
|
||||
"heatAlert": true
|
||||
},
|
||||
"started": 81160.254818568,
|
||||
"deadline": 81220.254818568,
|
||||
"lastSample": 81230.770869338,
|
||||
"used": 0,
|
||||
"nextBreak": 60,
|
||||
"stillSent": false,
|
||||
"warned": 81170.389722976,
|
||||
"events": [
|
||||
{
|
||||
"id": "2f03a8c2eb88482ca28b10246ace496b:1",
|
||||
"kind": "started",
|
||||
"message": "Session started. Steam Home opens at the limit; games are not closed.",
|
||||
"time": 1790676762.9003663
|
||||
},
|
||||
{
|
||||
"id": "2f03a8c2eb88482ca28b10246ace496b:2",
|
||||
"kind": "warning",
|
||||
"message": "One minute left. Save your progress; Steam Home will open.",
|
||||
"time": 1790676773.0352101
|
||||
},
|
||||
{
|
||||
"id": "2f03a8c2eb88482ca28b10246ace496b:3",
|
||||
"kind": "finished",
|
||||
"message": "Session ended: Steam Home opened. Your game is still running.",
|
||||
"time": 1790676833.7960055
|
||||
}
|
||||
],
|
||||
"seq": 3,
|
||||
"latched": [],
|
||||
"error": null,
|
||||
"time": 1790676838.9175165,
|
||||
"remaining": 0,
|
||||
"heartbeat": 81230.770869338,
|
||||
"unavailable": [],
|
||||
"activity": 3
|
||||
},
|
||||
"cleanup": {
|
||||
"prior_page_restored": true,
|
||||
"prior_dashboard_visibility_restored": true,
|
||||
"worker_exited": true,
|
||||
"temporary_simulator_key_removed": true,
|
||||
"phone_server_exited": true,
|
||||
"lock_released": true,
|
||||
"owned_simulator_shutdown": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Family and comfort: locked real-Frame verification
|
||||
|
||||
**Verified 2026-09-29**, tested feature source at `be1ab12`, SteamOS 0.4.1 build
|
||||
`20260925.6191901`, iOS 26.5 Simulator. The test acquired
|
||||
`/tmp/frame-test.lock` before deploying or launching anything.
|
||||
|
||||
The Simulator installed the bundled server in the Frame user account, connected
|
||||
over its SSH tunnel, requested notification permission, displayed a native test
|
||||
banner, and started a one-minute session through the shared UI API. The real
|
||||
session warning reached the Simulator as a native banner. Steam Home opened
|
||||
**60.76 seconds after the successful warning**. The running-app list was empty
|
||||
before and after; this run does not prove preservation of a running game.
|
||||
|
||||

|
||||
|
||||
[Recorded session and cleanup result](comfort-session-20260929.json).
|
||||
Local raw logs and headset/Simulator captures are retained at
|
||||
`/tmp/frame-comfort-evidence/locked-run/` on the verification Mac.
|
||||
|
||||
**Verified cleanup:** previous page and hidden-dashboard state restored; comfort
|
||||
worker and phone server exited; temporary Simulator SSH key removed; test lock
|
||||
released; only the owned Simulator shut down. Battery stayed at 100%. No global
|
||||
settings, power state, Steam or SteamVR lifecycle changes were made.
|
||||
|
||||
**Unverified while unworn:** SteamVR activity was 3 (standby), so active-use
|
||||
break/check-in clocks stayed at zero. Headset captures returned blank standby
|
||||
frames. The casting shortcut was invoked, but this run does not establish
|
||||
visible output or frame rate. No low-battery or overheating condition was
|
||||
induced. Earlier worn/active-device results remain separately documented in
|
||||
[Family and comfort](../family-comfort.md).
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"os": {
|
||||
"version": "0.4.1",
|
||||
"build": "20260925.6191901",
|
||||
"variant": "vr"
|
||||
},
|
||||
"performance": {
|
||||
"compositorFps": 72.0,
|
||||
"frameMs": 13.89,
|
||||
"appFps": null,
|
||||
"gpuMs": 3.07,
|
||||
"compositorCpuMs": 0.61,
|
||||
"cpuPercent": 40.6,
|
||||
"gpuMHz": 903.0
|
||||
},
|
||||
"batteryPercent": 16,
|
||||
"maxTempC": 73.5,
|
||||
"ownership": [
|
||||
{
|
||||
"id": 1009850,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 1173510,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 1068820,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 908520,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 1494460,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
}
|
||||
],
|
||||
"hudLifecycle": "open, duplicate-open, close passed before control-test pause; overlay probe removal verified",
|
||||
"comfort": "Initial 1cm seated probe restored exactly. Later commits/readback disagreed while Frame in use; Alex paused control tests. No controls shipped."
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 269 KiB |
@@ -1,58 +0,0 @@
|
||||
# Independent review attempts — 2026-09-28
|
||||
|
||||
Target: the implementation and evidence in
|
||||
[3fb541d](https://github.com/saphid/frame-control/commit/3fb541d) and
|
||||
[6a63a5a](https://github.com/saphid/frame-control/commit/6a63a5a), supplied as a
|
||||
frozen diff before those commits were made. No executable code changed after
|
||||
the final review snapshot.
|
||||
|
||||
Requested model: **SWE-2 Max**, explicitly selected with `--model swe-2-max`.
|
||||
No completed verdict or model self-identification was returned. This is not a
|
||||
passed review, and there is no "no actionable findings" claim.
|
||||
|
||||
## First attempt
|
||||
|
||||
Launcher:
|
||||
|
||||
```sh
|
||||
devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-prompt.txt
|
||||
```
|
||||
|
||||
The prompt required read-only review, no delegation, no edits and no Frame
|
||||
access. The reviewer inspected surrounding code, then stopped while checking
|
||||
the public OpenVR header. The tool runner reported:
|
||||
|
||||
> warning: rejected a tool call that requires confirmation. Running in non-interactive mode.
|
||||
|
||||
The real launcher exit status was **0**, but no verdict was returned. A zero
|
||||
process status here is not evidence that the review completed.
|
||||
|
||||
## Tool-free retry
|
||||
|
||||
Launcher:
|
||||
|
||||
```sh
|
||||
devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-final-prompt.txt
|
||||
```
|
||||
|
||||
The self-contained prompt supplied the complete frozen changes, surrounding
|
||||
code, standards and locally fetched authoritative OpenVR header excerpts. It
|
||||
explicitly prohibited tools, edits, delegation and device access. This avoided
|
||||
the first attempt's permission boundary without escalating permissions.
|
||||
|
||||
No output or verdict arrived within the fifteen-minute review window. The
|
||||
process was sent SIGTERM at 918 seconds; the shell recorded real exit status
|
||||
**143**. Findings are unavailable. Review must be completed before considering
|
||||
this partial draft ready; playspace feasibility is also still paused.
|
||||
|
||||
## Other validation
|
||||
|
||||
- 166 Python unit tests passed locally.
|
||||
- 8 website tests and UI JavaScript syntax passed locally.
|
||||
- Desktop and phone-width attached-preview checks passed using live telemetry;
|
||||
paid optional install buttons were disabled, and unavailable metrics cleared.
|
||||
- [Fake-Frame CI](https://github.com/saphid/frame-control/actions/runs/36423548487/job/108931878908)
|
||||
passed, as did Windows/Linux server tests and the main checks job. Docker was
|
||||
unavailable locally. macOS/iOS jobs were still queued at this handoff.
|
||||
- Real-device evidence and the paused-control limitation are in
|
||||
[VR utilities](../../vr-utilities.md).
|
||||
@@ -40,8 +40,6 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
|
||||
| **SteamOS updates arrive on their own.** The Frame went from 0.3.0 (build 20260922.6101926) to **0.4.1, build 20260925.6191901**, between 2026-09-27 and 2026-09-28 with no action from us; `~` (keys, user Flatpaks, `~/.local/share`) survived. **Verified 2026-09-28.** | Keep changes in `~` |
|
||||
| **Valve's package repository has more than the image.** `pacman -Si` / `pacman -Sp` work as `steamos` without root and list Valve's own builds, such as `kdeconnect` 24.02.2 and `python-evdev` 1.7.0 in `extra`. Unpacking those packages into `~` runs them without touching the read-only root. The repository URLs say not to share them, so never write them down; Valve also publishes each build's source package there (`sources/packages/`), which is how Frame Control got the complete source for the KDE Connect it ships. **Verified 2026-09-28**, SteamOS 0.4.1. | [streaming.md](streaming.md#input-type-and-point-in-the-frame-from-the-mac-or-iphone) |
|
||||
| **gamescope has its own input injection.** An EIS socket at `/run/user/1000/gamescope-0-ei` (libei 1.4.1 is on the image) offers "Gamescope Virtual Input": relative and absolute pointer, buttons, scroll, keyboard. It drives the panel that has focus in the headset, on either X display. Focus moves only with the controller's laser (or to a new panel when none has it); `gamescopectl focus_info` prints the focus state to the journal. **Verified 2026-09-29.** | [streaming.md](streaming.md#live-view-and-control-watch-a-panel-and-tap-on-it) |
|
||||
| **A panel's own pixels:** `ffmpeg -f x11grab -window_id <window> -i :<display>` captures one window (x11grab of the root is black under gamescope). Panels live on `:0` (Steam's UI, windows tagged by `panel-on-frame.sh`) or `:1` (apps Steam starts). **Verified 2026-09-29.** | Frame Control's Desktop view |
|
||||
| **gamescope runs two Xwayland displays.** `:0` holds Steam's VR bar and menus (`valve.steam.gamepadui.*`) and ignores XTest pointer motion; `:1` holds apps such as Chromium and takes it. There's also a libei socket, `/run/user/1000/gamescope-0-ei`. **Verified 2026-09-28**, SteamOS 0.4.1. | Keyboard and trackpad |
|
||||
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
|
||||
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
@@ -468,18 +468,3 @@ versus on, medians of the runs, ms):
|
||||
window to the front first.
|
||||
- Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired
|
||||
with the Frame.
|
||||
|
||||
## Switching panels and workspace limits
|
||||
|
||||
**Tools → Panel switcher** lists open SteamVR panels, including Mac viewers.
|
||||
Use **Show** to request focus or **Open in headset** for Frame Control's own
|
||||
switcher panel. It uses SteamVR/gamescope and Chromium, with no third-party
|
||||
overlay app. [Device checks and limits](panels.md#frame-controls-panel-switcher)
|
||||
include the difference between a panel surviving a scene launch and staying
|
||||
visible over it.
|
||||
|
||||
Saved spatial layouts are blocked on this build: the public OpenVR transform
|
||||
setter denies access to gamescope-owned panels. Reconnecting an existing viewer
|
||||
is supported; restoring its room position after a reboot is not. We do not
|
||||
save short-lived Mac window IDs or viewer access keys as if they were a durable
|
||||
workspace. See [the feasibility evidence](panels.md#saved-spatial-layouts-blocked-on-the-current-panel-route).
|
||||
+2
-159
@@ -121,165 +121,8 @@ a limit on the number of floating panels.
|
||||
script needed.
|
||||
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||
- **Optional overlay tools:** Desktop+, OVR Toolkit and similar software are
|
||||
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
|
||||
headset creates its own gamescope panel using built-in tools. It needs no
|
||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||
|
||||
## Frame Control's panel switcher
|
||||
|
||||
**Verified 2026-09-28**, SteamOS 0.4.1, BUILD_ID `20260925.6191901`,
|
||||
SteamVR 2.18.1: **Tools → Panel switcher** lists SteamVR's open main panels,
|
||||
including panels that are currently hidden. **Show** asks SteamVR to bring one
|
||||
forward. **Open in headset** opens the same switcher as its own panel; choose
|
||||
it again from Steam's dashboard after switching away. Refresh updates the list.
|
||||
This is a list, not thumbnail Exposé.
|
||||
|
||||

|
||||
|
||||
This is our own Python/HTML implementation (`ui/frame_panels.py`), using the
|
||||
Frame's shipped `vrcmd` OpenVR client and gamescope. The headset page uses
|
||||
Chromium (Chromium XR when present, then system Chromium, then the existing
|
||||
Chromium Flatpak). No XSOverlay, OVR Toolkit, WayVR or other overlay application
|
||||
is needed. This dependency boundary also applies to future layout and panel
|
||||
persistence work: platform APIs and bundled libraries are fine; another app
|
||||
must not implement the feature for us.
|
||||
|
||||
The companion runs the helper over SSH. Opening it in the headset installs a
|
||||
copy under `~/.local/share/frame-control/panels/` and starts a loopback HTTP
|
||||
server and an isolated Chromium profile. There is no startup service or global
|
||||
setting change. Close the switcher to stop its server and browser. Other
|
||||
Chromium profiles, Steam and SteamVR are left alone. If the window or runtime
|
||||
closes, use **Open in headset** again.
|
||||
|
||||
The page carries a random, per-process access key in its URL fragment, removes
|
||||
it from the address bar, keeps it in tab session storage for page reloads, and
|
||||
sends it in a header. Panel lists and actions need
|
||||
that key; Host and Origin checks reject other sites. The key permits only
|
||||
listing panels, requesting focus and closing this switcher. Like Mac viewer
|
||||
launch tickets, it is initially readable by another process running as the
|
||||
same Frame user. Panel titles are rendered as text, never HTML. The companion
|
||||
retains its existing request guards. No Mac capture credentials cross this API.
|
||||
|
||||
**Verified:** the real headset page rendered its panel list (image above), its
|
||||
HTTP focus request changed `GAMESCOPE_FOCUSED_APP` to `2000999030`, a request
|
||||
without the key returned HTTP 403, and Close stopped the helper and its browser.
|
||||
Opening an already running switcher requests its focus rather than creating a
|
||||
second one. The companion uses the same list/focus helper. **Unverified:** laser
|
||||
selection while wearing the headset, physical placement, and non-XR Chromium.
|
||||
The API reports that focus was *requested*: another action can take focus before
|
||||
we observe the result. Closed panels are rejected after re-enumeration.
|
||||
|
||||
### Shared-device recheck, 2026-09-29
|
||||
|
||||
**Verified:** the follow-up's atomic `mkdir /tmp/frame-test.lock` attempts
|
||||
failed because another thread held the lock. The existing lock was left alone;
|
||||
no applications were installed, launched or stopped in this follow-up. The last
|
||||
read-only battery check showed 62%, charging. The 180 Python and 8 website tests
|
||||
passed again locally.
|
||||
|
||||
**Unverified in this follow-up:** the prepared browser-button test (Refresh,
|
||||
selection, reload and Close) and repeated OpenXR transition could not run under
|
||||
the shared lock. The device results elsewhere in this page are the earlier
|
||||
2026-09-28 observations, not results from this blocked recheck. In particular,
|
||||
HTTP focus is not evidence of worn-headset laser input. Follow the
|
||||
[shared-device test procedure](testing.md#headset-smoke-test) for the next run.
|
||||
|
||||
## Saved spatial layouts: blocked on the current panel route
|
||||
|
||||
**Verified 2026-09-28**, same build, using a temporary xterm panel with
|
||||
`STEAM_GAME=2000999031` and `FnTable:IVROverlay_028` from
|
||||
`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`:
|
||||
|
||||
| OpenVR call | Result |
|
||||
|---|---|
|
||||
| `FindOverlay("valve.steam.desktopgame.2000999031")` | Success |
|
||||
| `GetOverlayWidthInMeters` | Success, 2.67 m |
|
||||
| `SetOverlayWidthInMeters` (same width) | Success |
|
||||
| `GetOverlayTransformType` | Success, type 5 (`VROverlayTransform_DashboardTab`) |
|
||||
| `GetOverlayTransformAbsolute` | 18 (`WrongTransformType`) |
|
||||
| `SetOverlayTransformAbsolute` (identity rotation, 1.2 m up, 1.5 m forward) | 12 (`PermissionDenied`); type remained 5 |
|
||||
|
||||
The public interface names type 5 **DashboardTab**; SteamVR's dashboard code
|
||||
places these panels through its scene graph. It owns the frame/docking
|
||||
transforms. A successful width setter does not grant permission to restore the
|
||||
position. `vrcmd --dock-overlay world <key>` dispatched a docking request but
|
||||
the dashboard logged `Failed to get SGTransform in setInitialTransformForLocation.
|
||||
Invalid transform ID`. This does not establish working world placement.
|
||||
|
||||
**Inferred:** saving X11 pixel rectangles or Mac window IDs would not restore
|
||||
this spatial arrangement. Mac window IDs also change when an application
|
||||
reopens; viewer tickets and reconnect keys must not go into a layout file.
|
||||
The base Mac stream reconnects after a network break, but that is different
|
||||
from recreating windows and their room positions after a reboot.
|
||||
|
||||
There is consequently no Save/Restore control yet. A durable layout needs a
|
||||
working transform restore path, stable source identity, and a fresh capture
|
||||
permission/ticket flow. The tested gamescope-owned overlay route denies that
|
||||
transform operation. A future Frame Control-owned overlay renderer, or a
|
||||
supported platform API for dashboard frame transforms, needs its own device
|
||||
proof before building layout UI. This is a blocker for the current approach,
|
||||
not a claim that all possible implementations are impossible. Reboot recovery
|
||||
was not tested: the shared headset was not rebooted.
|
||||
|
||||
## Panels during an immersive session
|
||||
|
||||
**Verified 2026-09-28**, same build: our Chromium switcher panel remained in
|
||||
OpenVR's overlay list before, during and after the Frame's shipped `helloxr -g
|
||||
Vulkan` sample. During the test `vrcmd --stats` identified
|
||||
`system.generated.openxr.helloxr.helloxr`, with 242 frame submissions. The test
|
||||
ended only its own sample process; no SteamVR, Steam, power or global settings
|
||||
were changed. The switcher was still selectable afterwards.
|
||||
|
||||
This proves survival of that panel across an OpenXR scene session, **not** that
|
||||
it stayed visibly composited over the scene: OpenVR reported it `not_visible`
|
||||
before, during and after. **Verified:** calling `ShowOverlay` on our
|
||||
*gamescope-owned* switcher overlay returns 12 (`PermissionDenied`). A helper
|
||||
cannot force that panel visible using the public overlay call. Use the
|
||||
switcher/dashboard to request access to it; we do not fight the runtime with a
|
||||
repeated force-focus loop.
|
||||
|
||||
**Verified in a second controlled run:** a live H.264 test-pattern stream from
|
||||
this checkout's Mac helper, through its own SSH tunnel and a temporary Chromium
|
||||
profile, survived the same OpenXR sample (257 scene-frame submissions). Its
|
||||
panel `2000999032` changed from `visible` before launch to `not_visible` during
|
||||
and after the scene. The Mac helper still reported the same `test` stream;
|
||||
captured frames increased from 35 to 232, with 29.5 decoded/drawn fps afterwards.
|
||||
The test did not capture personal Mac windows or inject Mac input. The sample,
|
||||
viewer, temporary profile, tunnel and Mac helper were cleaned up. This proves
|
||||
stream survival, and also shows why it must not be advertised as always visible.
|
||||
|
||||
**Unverified:** persistent visible placement while playing a Steam-launched VR
|
||||
game, Plasma desktop and real Mac-window behavior during that launch, and worn
|
||||
headset input. Other threads were launching games and changing the runtime on
|
||||
the shared device, so those transitions were not treated as controlled evidence.
|
||||
A runtime/X-server restart can destroy the viewer windows; a network reconnect
|
||||
cannot recreate them. No “always visible during games” guarantee is shipped.
|
||||
|
||||
## Keyboard passthrough feasibility
|
||||
|
||||
**Verified 2026-09-28**, same build, using `FnTable:IVRTrackedCamera_006`:
|
||||
`HasCamera(0)` returned success and true. `GetCameraFrameSize` returned 100
|
||||
(`OperationFailed`), with zero dimensions, for all three public frame types
|
||||
(distorted, undistorted and maximum-undistorted), including after acquiring the
|
||||
video service. Acquisition returned success and a handle; release returned 101
|
||||
(`InvalidHandle`). The probe shut down its OpenVR client afterwards. No camera
|
||||
frames were captured and no camera settings were changed.
|
||||
|
||||
**Documented:** the public OpenVR camera interface provides camera frame sizes,
|
||||
intrinsics, projections and streaming handles; these are prerequisites for a
|
||||
spatially aligned camera cutout. See Valve's
|
||||
[OpenVR C API](https://github.com/ValveSoftware/openvr/blob/master/headers/openvr_capi.h).
|
||||
|
||||
**Inferred:** camera presence alone does not establish access to camera pixels.
|
||||
The failed frame-size path blocks a keyboard cutout in our current panel
|
||||
implementation. We have not established a keyboard detector or a calibrated
|
||||
camera-to-panel mapping. Built-in full-room passthrough is not proof of a
|
||||
public, selectively masked camera stream. No keyboard cutout is offered, and
|
||||
no third-party camera/overlay app is substituted for it.
|
||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
||||
|
||||
## Frame Control's media theatre
|
||||
|
||||
|
||||
@@ -110,11 +110,3 @@ returns nothing without `cc`, so Frame Control takes the country from
|
||||
- Installing when there's more than one library folder, such as a microSD card.
|
||||
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
||||
headset.
|
||||
|
||||
## Optional VR software
|
||||
|
||||
The [VR utilities list](vr-utilities.md#optional-software) is separate from our
|
||||
controls and HUD. It checks software ownership as well as games; paid utilities
|
||||
are installable only when already in the loaded Frame account library. No
|
||||
purchase flow is added. Public reports, local results and ownership are shown
|
||||
separately, and untested tools remain untested.
|
||||
+2
-62
@@ -8,7 +8,6 @@ This covers three directions, plus input:
|
||||
- **PC VR from Linux**: [feasibility and options](linux-vr-streaming.md),
|
||||
including Valve's streaming and USB support. No Linux host tested yet.
|
||||
- **Input**: type and point in the Frame from the Mac or iPhone.
|
||||
- **Live view and Control**: watch a panel flat and tap on it to use it.
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
@@ -185,72 +184,13 @@ on the Frame, which talks KDE Connect's own LAN protocol to the Frame's
|
||||
and ignores injected pointer motion; `:1` holds apps such as Chromium and
|
||||
takes it. KDE Connect runs on `:1`, so it reaches apps, not Steam's own menus.
|
||||
There's also a `gamescope-0-ei` (libei) socket.
|
||||
- Typing through KDE Connect lands in a Chromium panel on `:1` (seen in the
|
||||
panel's own capture, 2026-09-29). It **can't reach panels on `:0`** (Frame
|
||||
Control's own panels, Steam's UI) and, since XTest positions are clamped to
|
||||
`:1`'s 1280×720 root, can't reach beyond that in a bigger window. Control on
|
||||
the live view (below) has neither limit.
|
||||
- **Not yet tested:** whether it reaches the KDE desktop panel (Plasma is its
|
||||
own session).
|
||||
- **Not yet tested:** typing and clicking as seen in the headset, and whether
|
||||
it reaches the KDE desktop panel (Plasma is its own session).
|
||||
- **Known limit:** keys and clicks typed while the link is reconnecting wait
|
||||
and are sent once it's back, but anything sent in the moment the Wi-Fi
|
||||
drops, before SSH notices, can be lost. Confirming every event would add a
|
||||
round trip to each pointer move.
|
||||
|
||||
## Live view and Control: watch a panel and tap on it
|
||||
|
||||
**Built: Home → Desktop / Headset view → Control.** The live view has two
|
||||
sources:
|
||||
|
||||
- **Headset view**: what the lenses show (SteamVR's mirror, `/dev/video99`). It
|
||||
moves with the wearer's head, so Control makes the view a trackpad: drag to
|
||||
move the pointer, tap to click, press and hold to right-click, two fingers to
|
||||
scroll. With a mouse, moving over the view moves the pointer.
|
||||
- **Desktop**: the app panel in use in the headset, from its own window, so it
|
||||
stays still however the wearer looks around. Control makes taps and clicks
|
||||
land exactly where you put them. Dragging is a mouse drag, press and hold is a
|
||||
right-click, two fingers scroll, and on a computer the mouse, wheel and
|
||||
keyboard work directly on it (⌘ is sent as Ctrl on a Mac). A picker shows any
|
||||
other panel, view only.
|
||||
|
||||
Below the view, a text field and key buttons type on the Frame from a phone.
|
||||
|
||||
How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
|
||||
|
||||
- **Input goes through gamescope's own injection.** gamescope serves an EIS
|
||||
socket (`/run/user/1000/gamescope-0-ei`; Steam feeds Remote Play input through
|
||||
it), and `libei` 1.4.1 is on the image. [`ui/frame_touch.py`](../ui/frame_touch.py)
|
||||
talks to it with `ctypes`: nothing to install. gamescope offers one device,
|
||||
"Gamescope Virtual Input", with relative and absolute pointer, buttons,
|
||||
scroll and keyboard (Linux key codes; no text capability, so the text field
|
||||
types printable ASCII on a US layout). Its absolute region is unbounded; the
|
||||
pointer uses the focused panel's display coordinates, and gamescope fits each
|
||||
window to its display, so a 1920×1080 window on the 1280×720 `:1` takes
|
||||
positions at two thirds scale. Taps on a 1280×720 page landed on the exact
|
||||
pixel.
|
||||
- **It reaches the panel that has focus** (`GAMESCOPE_FOCUSED_WINDOW` on `:0`'s
|
||||
root), on either X display. In the OpenVR backend focus moves only on SteamVR
|
||||
overlay events (the controller's laser entering or clicking a panel), or to a
|
||||
new panel when none holds it (read from gamescope's `OpenVRBackend.cpp`, seen
|
||||
with `gamescopectl focus_info`, which writes to the journal). Neither
|
||||
`GAMESCOPECTRL_BASELAYER_WINDOW`/`_APPID` nor X focus moves it, and no
|
||||
gamescope command does. So Control follows the wearer: whatever they last
|
||||
used is what your taps reach. A window without a Steam app id (`STEAM_GAME`)
|
||||
gets a connector of its own and doesn't hold focus.
|
||||
- **Keys in a burst can arrive out of order**, so the helper paces them (8 ms
|
||||
apart).
|
||||
- **Known limit:** if focus moves to another panel in the middle of a drag, the
|
||||
release goes to the panel that has focus then. Whether gamescope hands it to
|
||||
the window that got the press isn't known yet. When the session ends, the
|
||||
helper lets go of every button and key it still holds.
|
||||
- **The Desktop picture is the window's own pixels**: `ffmpeg -f x11grab
|
||||
-window_id <window> -i :<display>` works on gamescope's redirected windows,
|
||||
while grabbing the root gives black. It streams as H.264 like the headset view
|
||||
(about 30 fps at 720p).
|
||||
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
|
||||
text box in the panel and the text field typed into it; a trackpad move went
|
||||
exactly (+40, +25).
|
||||
|
||||
Our own `uinput` keyboard and mouse would also work (`steamos` is in the
|
||||
`input` group and `/dev/uinput` is group-writable, verified 2026-09-27), and
|
||||
remains the fallback if the bundled KDE Connect ever stops working on a new SteamOS.
|
||||
|
||||
@@ -104,20 +104,6 @@ switch while SSH is down:
|
||||
|
||||
## Headset smoke test
|
||||
|
||||
**Documented shared-device procedure:** before a test installs, launches or
|
||||
stops an application, acquire `ssh frame 'mkdir /tmp/frame-test.lock'`. If it
|
||||
fails, leave that lock alone and continue offline work. Only the thread that
|
||||
acquired it releases it with `ssh frame 'rmdir /tmp/frame-test.lock'`, after
|
||||
cleanup. Keep each device session to a few minutes.
|
||||
|
||||
Check battery capacity and charging state under `/sys/class/power_supply`
|
||||
before and after; keep capacity above 20%. Stop only processes started by the
|
||||
test, remove temporary installs and profiles, and restore the prior dashboard
|
||||
state. Leave Steam and SteamVR running. Do not reboot or change global settings.
|
||||
Record the build, actual interaction results, cleanup and any unworn-headset
|
||||
limits alongside screenshots or logs. These are caller responsibilities; the
|
||||
smoke script below does not acquire this shared lock itself.
|
||||
|
||||
```sh
|
||||
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
|
||||
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
|
||||
@@ -193,20 +179,3 @@ late/suspended sessions, cancellation, failed actions, duplicate alerts, reboot
|
||||
invalidation, per-zone thermal trips and shared on-headset state. The server
|
||||
guards reject invalid session settings before SSH. See
|
||||
[real-device evidence and limits](family-comfort.md#verification).
|
||||
|
||||
## Panel switcher
|
||||
|
||||
`tests/test_panels.py` supplies fake-Frame `vrcmd --overlays` output, checks
|
||||
main-panel filtering (including hidden panels), revalidates closed panels before
|
||||
focus, and drives the headset helper's real loopback HTTP server to test access
|
||||
keys, Host/Origin guards, malformed requests, offline errors and Close. It runs
|
||||
in the normal unit suite without OpenVR or a headset. The fixture format comes
|
||||
from SteamVR 2.18.1, BUILD_ID `20260925.6191901`; it does not simulate rendering.
|
||||
|
||||
On the Frame, run `python3 -` over SSH with `ui/frame_panels.py` on stdin to
|
||||
list panels. `--focus <key>` rechecks the list and requests focus. In Frame
|
||||
Control, **Tools → Panel switcher → Open in headset** exercises installation,
|
||||
Chromium rendering and the same helper through HTTP. Close the switcher after
|
||||
testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
|
||||
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
|
||||
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
|
||||
@@ -1,139 +0,0 @@
|
||||
# VR comfort and performance
|
||||
|
||||
Frame Control owns its HUD and telemetry. They use SteamVR/OpenVR, gamescope,
|
||||
Python and xterm already on the Frame, plus the Frame's sensors. No feature
|
||||
requires fpsVR, OVR Advanced Settings, XSOverlay or another third-party app.
|
||||
The software list is a separate, optional convenience.
|
||||
|
||||
This is **part of [#25](https://github.com/saphid/frame-control/issues/25)**,
|
||||
not completion of the issue. Playspace controls remain blocked on the device
|
||||
checks below. The PR stays draft.
|
||||
|
||||
## Our performance HUD
|
||||
|
||||
On **Home → VR comfort and performance**, 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
|
||||
it to a controller. **Close HUD**, or closing its terminal, ends it. Opening
|
||||
it twice reuses the existing process.
|
||||
|
||||
| Value | Meaning and source |
|
||||
|---|---|
|
||||
| Compositor FPS / period | Differences between two `IVRCompositor_029::GetFrameTiming` frame indices and monotonic compositor timestamps, sampled 200 ms apart. Output cadence, not game FPS or a long-term average. |
|
||||
| Application FPS | Reciprocal of OpenVR's client frame interval. Unavailable if there is no positive interval; not inferred from refresh rate. |
|
||||
| Render GPU time | OpenVR total render GPU milliseconds, not GPU utilisation. |
|
||||
| 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`. |
|
||||
|
||||
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.
|
||||
|
||||
Missing sensors, a stopped or incompatible SteamVR runtime, and non-advancing
|
||||
frame indices display **Unavailable**, never invented zero FPS. Failed status
|
||||
refreshes clear the HUD card rather than keeping a stale live-looking sample.
|
||||
The HUD itself adds CPU/GPU work; it is a diagnostic, not a zero-overhead benchmark.
|
||||
|
||||
**Verified 2026-09-28**, SteamOS 0.4.1, build `20260925.6191901`, SteamVR
|
||||
2.18.1: the exact OpenVR interface and 192-byte timing layout returned advancing
|
||||
frame indices and live GPU/CPU timing. Sensor reads, creation of overlay
|
||||
`valve.steam.desktopgame.2000250025`, duplicate-open handling, and closing the
|
||||
HUD passed. The temporary probe overlay disappeared after its process closed.
|
||||
[Sanitized device sample](evidence/vr-utilities/device.json).
|
||||
|
||||
**Unverified:** visual placement while wearing the headset, controller docking,
|
||||
and overhead during gameplay. The companion card was checked in the attached
|
||||
preview using live Frame data. Creating an overlay does not establish that it
|
||||
was visible to the wearer.
|
||||
|
||||
The optional HUD copies only `frame_status.py` and `frame_vr.py` into
|
||||
`~/.local/share/frame-control/vr/`. It tags only the window whose X11 PID matches
|
||||
its own xterm, avoiding other threads' windows. Stop checks both PID and Linux
|
||||
process start time before sending SIGTERM. It does not stop Steam or SteamVR,
|
||||
edit their settings, install a service, or need sudo.
|
||||
|
||||
## Playspace, seated height and recenter: paused
|
||||
|
||||
**Verified 2026-09-28:** SteamVR exposes `IVRChaperoneSetup_006` and
|
||||
`IVRChaperone_004`. An initial 1 cm seated zero-pose translation committed,
|
||||
read back and restored numerically. A later trial, while the shared Frame was
|
||||
in use, changed universe IDs after commits and returned transforms that did
|
||||
not match the requested write or restore. Journal entries reported
|
||||
`CommitWorkingCopy`, `VREvent_ChaperoneUniverseHasChanged` and
|
||||
`VREvent_ChaperoneRoomSetupCommitted`. The collision-bound arrays and play-area
|
||||
size matched in the saved before/after records, but the origin matrices did not.
|
||||
|
||||
Alex confirmed the headset was in use and asked to pause control tests. No
|
||||
further playspace writes were made. The exploratory control implementation was
|
||||
removed from the shipping API; `recenter`, `adjust` and `restore` are rejected.
|
||||
This is an **unresolved feasibility check**, not evidence that OpenVR controls
|
||||
cannot work. Concurrent use and the Frame driver's coordinate-system handling
|
||||
still need to be separated.
|
||||
|
||||
The probe's original and last-read poses remain in the Frame's
|
||||
`~/.local/share/frame-control/vr/comfort.json` for investigation. This draft
|
||||
neither reads nor applies that baseline. Do not blindly replay it into a room
|
||||
that may have changed. No recenter test was reached in the later trial.
|
||||
|
||||
Before adding controls, on an idle Frame:
|
||||
|
||||
1. Establish current room and tracking state, and inspect the retained probe
|
||||
evidence before considering any restoration.
|
||||
2. Prove seated and standing height/move operations in the Frame driver's
|
||||
current coordinates, including delayed readback, coordinate rebasing and
|
||||
recovery. Show that the physical safety boundary stays correct.
|
||||
3. Verify recenter independently, and test a full apply/restore cycle plus a
|
||||
concurrent room-change refusal. Add a fake OpenVR test for those contracts.
|
||||
4. Check the apparent result in a seated and a standing app before exposing UI.
|
||||
|
||||
**Documented:** seated and standing are tracking origins selected by an app;
|
||||
changing a seated origin cannot force every game to support seated play.
|
||||
|
||||
**Inferred from the installed SteamVR defaults:** there is no generic snap-turn
|
||||
or locomotion-vignette setting. `dashboard.verticalOffsetCm_2` and
|
||||
`steamvr.panelMaskVignette` affect panels, not the player's height or game
|
||||
locomotion. The app gives game-setting hints for snap-turn, teleport movement
|
||||
and movement vignette instead of writing these unrelated settings.
|
||||
|
||||
## Optional software
|
||||
|
||||
**Verified 2026-09-28 (same build):** a read-only query of Steam's loaded
|
||||
`appStore.allApps` found none of these five apps on the Frame account. The query
|
||||
includes software, which the existing games-only library filter excludes.
|
||||
Steam's public app-details API listed only Desktop+ as free. No software was
|
||||
purchased, installed or launched during these ownership checks.
|
||||
|
||||
| Utility | Local Frame status | Public evidence / optional source |
|
||||
|---|---|---|
|
||||
| OVR Advanced Settings (1009850) | **Untested**, not owned | Steam edition is paid. Developer's [free source and releases](https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings). No verified Frame result in this work. |
|
||||
| XSOverlay (1173510) | **Untested**, not owned | Supplied research attributes Proton support with tweaks to [Road to VR](https://www.roadtovr.com/valve-steam-frame-review/). This is a public report, not our verification. |
|
||||
| OVR Toolkit (1068820) | **Untested**, not owned | Same [public Frame report](https://www.roadtovr.com/valve-steam-frame-review/); no local verification. |
|
||||
| fpsVR (908520) | **Untested**, not owned | [Steam listing](https://store.steampowered.com/app/908520/). No Frame-specific result established in the supplied research. General PC VR reviews do not verify Frame support. |
|
||||
| Desktop+ (1494460) | **Untested**, free | [Developer source](https://github.com/elvissteinjr/DesktopPlus). No verified Frame result in this work. |
|
||||
|
||||
**Documented (supplied research):** the XSOverlay/OVR Toolkit claims are kept as
|
||||
attributed leads. **Verified source check 2026-09-28:** fetching the linked
|
||||
review returned HTTP 200 and the expected review title, but neither utility name
|
||||
appeared in the fetched HTML or extracted text. The claims could not be
|
||||
corroborated from that page; this is not proof of incompatibility. They do not make those apps dependencies or mark them
|
||||
locally verified. No paid utility is auto-acquired. A server-side check blocks
|
||||
installation through the Steam endpoint if the paid utility is absent from the
|
||||
loaded Frame library; an empty/unavailable library fails closed. Desktop+ uses
|
||||
the existing free Steam install flow, which may need a license confirmation in
|
||||
the headset. None is advertised as known-good without local evidence.
|
||||
|
||||
Compatibility reports reuse the existing `compat-db` storage and validation
|
||||
with `package: "steam:<appid>"`, rather than colliding with Android package IDs.
|
||||
The optional list shows the latest `works`, `issues` or `broken` report with its
|
||||
date, build and notes, separately from public sources and ownership. With no
|
||||
report the status stays **untested**. No shared database schema change or
|
||||
production deployment is needed; no reports were published by this work.
|
||||
|
||||
## Validation and review
|
||||
|
||||
166 unit tests and 8 website tests passed locally. The new fake-Frame cases
|
||||
passed in GitHub CI (Docker was unavailable locally). Desktop and phone-width
|
||||
preview checks passed. The two independent review attempts did not produce a
|
||||
verdict; [commands, real exit statuses and limitations](evidence/vr-utilities/review.md).
|
||||
@@ -83,27 +83,5 @@ class Device(harness.FrameTestCase):
|
||||
self.assertEqual(state()['steam']['pages'][0]['title'], 'Hades on Steam')
|
||||
|
||||
|
||||
class VRUtilities(harness.FrameTestCase):
|
||||
def test_missing_vr_runtime_is_unavailable_not_zero_fps(self):
|
||||
data = ok('GET', '/api/status')
|
||||
self.assertIsNone(data['performance']['compositorFps'])
|
||||
self.assertIsNone(data['performance']['appFps'])
|
||||
self.assertEqual(data['temp'], 41.5)
|
||||
self.assertEqual(data['battery']['percent'], 76)
|
||||
|
||||
def test_optional_paid_utilities_are_not_installed(self):
|
||||
# The fake library contains games but none of these paid software titles.
|
||||
for appid in (1009850, 1173510, 1068820, 908520):
|
||||
code, body, _ = api('POST', '/api/steam', {'action': 'install', 'appid': appid})
|
||||
self.assertEqual(code, 502, body)
|
||||
self.assertIn('not owned', body['error'])
|
||||
self.assertEqual(launches('install'), [])
|
||||
|
||||
def test_unverified_controls_are_not_exposed(self):
|
||||
for action in ('recenter', 'adjust', 'restore'):
|
||||
code, body, _ = api('POST', '/api/vr', {'action': action, 'origin': 'seated', 'y': .1})
|
||||
self.assertEqual(code, 400, body)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -27,7 +27,7 @@ function newShortcutId(steam) {
|
||||
function build(steam) {
|
||||
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
||||
const gameOverview = a => ({
|
||||
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: a.app_type ?? 1,
|
||||
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: 1,
|
||||
steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only,
|
||||
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
|
||||
rt_last_time_played: a.last_played || 0,
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
// Control on the live view (tap, drag, hold, scroll, type), run in node against the real
|
||||
// functions from ui/index.html with a fake canvas and a fake server.
|
||||
import { readFileSync } from "fs";
|
||||
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
|
||||
const grab = name => {
|
||||
const one = src.match(new RegExp(`\\n((?:async )?function ${name}\\(.*\\}\\n)`)); // one-line function
|
||||
if (one) return one[1];
|
||||
const m = src.match(new RegExp(`(?:\\nconst ${name} = [^\\n]*\\n)|((?:async )?function ${name}\\([\\s\\S]*?\\n}\\n)`));
|
||||
if (!m) throw new Error("not found: " + name);
|
||||
return m[0];
|
||||
};
|
||||
const NAMES = ["panelKey", "ctrlAimedAt", "ctrlSameTarget", "isMoveEvent", "isRelease", "ctrlKeepable", "TAP_MOVE", "ctrlAim", "ctrlKeyEvent", "ctrlTouchCancel", "ctrlSend", "ctrlFlush", "ctrlMoveTo", "ctrlMoveBy", "ctrlSchedule",
|
||||
"ctrlFlushMoves", "ctrlButton", "ctrlClick", "ctrlRelease", "ctrlFraction", "ctrlTouchDown", "centroid",
|
||||
"ctrlTouchMove", "ctrlTouchUp", "ctrlTap", "ctrlText"];
|
||||
const code = NAMES.map(grab).join("");
|
||||
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
|
||||
const tick = (ms = 0) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function page({ mode = "abs", rect = { left: 0, top: 0, width: 640, height: 360 }, api } = {}) {
|
||||
const target = { panel: { window: 42, display: ":1" } };
|
||||
const ctrl = { on: true, queue: [], sending: false, state: "ready", message: "", move: null, rel: [0, 0], raf: 0,
|
||||
held: new Set(), keys: new Set(), pointers: new Map(), g: null, retry: null };
|
||||
const sent = [];
|
||||
const canvas = { width: 1280, height: 720, getBoundingClientRect: () => rect };
|
||||
const env = {
|
||||
ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
|
||||
ctrlTarget: () => (mode !== "abs" ? { ok: true } : target.panel ? { ok: true, panel: target.panel } : { why: "gone" }),
|
||||
api: api || (async (path, body) => { sent.push(...body.events); return { state: "ready", sent: true }; }),
|
||||
requestAnimationFrame: cb => { setTimeout(cb, 0); return 1; },
|
||||
navigator: {},
|
||||
};
|
||||
const fns = new Function(...Object.keys(env), `let ctrlWarned = false;\n${code}
|
||||
return { ctrlTouchDown, ctrlTouchMove, ctrlTouchUp, ctrlTouchCancel, ctrlSend, ctrlText, ctrlButton, ctrlKeyEvent, ctrlRelease, ctrlFlushMoves };`)(...Object.values(env));
|
||||
const at = (id, x, y) => ({ pointerId: id, clientX: x, clientY: y });
|
||||
return { ctrl, sent, target, ...fns, at };
|
||||
}
|
||||
|
||||
// A tap lands where it was tapped: pointer there first, then the click.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 320, 90)); p.ctrlTouchUp(p.at(1, 320, 90));
|
||||
await tick(10);
|
||||
const [move, down, up] = p.sent;
|
||||
if (!(move.fx === 0.5 && Math.abs(move.fy - 0.25) < 1e-9 && move.window === 42 && move.display === ":1")) fail("tap position " + JSON.stringify(move));
|
||||
if (!(down.window === 42 && down.display === ":1")) fail("a press names its panel " + JSON.stringify(down));
|
||||
if (!(down.button === "left" && down.down && up.button === "left" && up.down === false && p.sent.length === 3))
|
||||
fail("tap click " + JSON.stringify(p.sent));
|
||||
}
|
||||
// The panel is letterboxed in a taller view: taps map inside it, taps on the bars do nothing.
|
||||
{
|
||||
const p = page({ rect: { left: 0, top: 0, width: 640, height: 480 } }); // 640x360 picture, 60px bars
|
||||
p.ctrlTouchDown(p.at(1, 320, 150)); p.ctrlTouchUp(p.at(1, 320, 150));
|
||||
p.ctrlTouchDown(p.at(2, 320, 10)); p.ctrlTouchUp(p.at(2, 320, 10));
|
||||
await tick(10);
|
||||
if (!(p.sent.length === 3 && Math.abs(p.sent[0].fy - 0.25) < 1e-9)) fail("letterbox " + JSON.stringify(p.sent));
|
||||
}
|
||||
// A drag is a mouse drag: down where it started, moves, up at the end.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 100, 100));
|
||||
p.ctrlTouchMove(p.at(1, 140, 100)); await tick(5);
|
||||
p.ctrlTouchMove(p.at(1, 200, 120)); await tick(5);
|
||||
p.ctrlTouchUp(p.at(1, 200, 120)); await tick(10);
|
||||
const kinds = p.sent.map(e => "fx" in e ? "move" : `${e.button}-${e.down ? "down" : "up"}`);
|
||||
if (!(kinds[0] === "move" && kinds[1] === "left-down" && kinds.at(-1) === "left-up" && kinds.includes("move", 2)))
|
||||
fail("drag " + kinds.join(","));
|
||||
if (Math.abs(p.sent[0].fx - 100 / 640) > 1e-9) fail("drag starts where the finger went down");
|
||||
if (p.ctrl.held.size) fail("drag left the button held");
|
||||
}
|
||||
// Press and hold is a right-click where the finger is.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 64, 36));
|
||||
await tick(620);
|
||||
p.ctrlTouchUp(p.at(1, 64, 36)); await tick(10);
|
||||
const buttons = p.sent.filter(e => "button" in e).map(e => `${e.button}-${e.down}`);
|
||||
if (buttons.join() !== "right-true,right-false") fail("hold " + buttons.join());
|
||||
if (!(p.sent[0].fx === 0.1)) fail("hold position " + JSON.stringify(p.sent[0]));
|
||||
}
|
||||
// Two fingers scroll, and the content follows them (fingers up scrolls down).
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 100, 200)); p.ctrlTouchDown(p.at(2, 200, 200));
|
||||
p.ctrlTouchMove(p.at(1, 100, 180)); p.ctrlTouchMove(p.at(2, 200, 180));
|
||||
p.ctrlTouchUp(p.at(1, 100, 180)); p.ctrlTouchUp(p.at(2, 200, 180));
|
||||
await tick(10);
|
||||
const dy = p.sent.filter(e => e.scroll).reduce((a, e) => a + e.scroll[1], 0);
|
||||
if (!(dy === 40 && !p.sent.some(e => "button" in e))) fail("scroll " + JSON.stringify(p.sent));
|
||||
}
|
||||
// On the headset view it's a trackpad: drags move the pointer, taps click where it is.
|
||||
{
|
||||
const p = page({ mode: "rel" });
|
||||
p.ctrlTouchDown(p.at(1, 100, 100));
|
||||
p.ctrlTouchMove(p.at(1, 110, 100)); p.ctrlTouchMove(p.at(1, 120, 105));
|
||||
p.ctrlTouchUp(p.at(1, 120, 105)); await tick(10);
|
||||
p.ctrlTouchDown(p.at(1, 50, 50)); p.ctrlTouchUp(p.at(1, 50, 50)); await tick(10);
|
||||
const moved = p.sent.filter(e => "dx" in e).reduce((a, e) => [a[0] + e.dx, a[1] + e.dy], [0, 0]);
|
||||
if (!(moved[0] === 32 && moved[1] === 8)) fail("trackpad move " + JSON.stringify(moved));
|
||||
if (p.sent.some(e => "fx" in e)) fail("trackpad sent an absolute position");
|
||||
if (p.sent.filter(e => e.button === "left").length !== 2) fail("trackpad tap " + JSON.stringify(p.sent));
|
||||
}
|
||||
// Text: plain ASCII only, in chunks the server takes.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlText("héllo " + "x".repeat(600));
|
||||
await tick(10);
|
||||
const text = p.sent.map(e => e.text).join("");
|
||||
if (!(text === "hllo " + "x".repeat(600) && p.sent.every(e => e.text.length <= 500))) fail("text " + text.length);
|
||||
}
|
||||
// Not connected yet: clicks and keys wait with their position. The request fails: only releases wait.
|
||||
{
|
||||
let calls = 0;
|
||||
const p = page({ api: async () => { calls++; if (calls === 1) return { state: "starting", sent: false }; throw new Error("offline"); } });
|
||||
p.ctrlSend([{ fx: 0.1, fy: 0.1, window: 42 }, { button: "left", down: true }, { key: 30, down: true }]);
|
||||
await tick(5);
|
||||
const q = p.ctrl.queue;
|
||||
if (!(q.length === 3 && "fx" in q[0] && q[1].button === "left" && q[2].key === 30)) fail("kept while starting " + JSON.stringify(q));
|
||||
clearTimeout(p.ctrl.retry);
|
||||
p.ctrl.queue = []; p.ctrl.retryAt = 0;
|
||||
p.ctrlSend([{ button: "left", down: false }, { key: 31, down: true }, { dx: 3, dy: 1 }]);
|
||||
await tick(5);
|
||||
if (!(p.ctrl.queue.length === 1 && p.ctrl.queue[0].button === "left" && p.ctrl.queue[0].down === false))
|
||||
fail("release kept on failure " + JSON.stringify(p.ctrl.queue));
|
||||
if (calls !== 2) fail("retried in a tight loop: " + calls + " requests");
|
||||
clearTimeout(p.ctrl.retry);
|
||||
}
|
||||
// Turning Control off (or the view losing focus) lets go of anything held.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlButton("left", true);
|
||||
p.ctrlRelease(); await tick(10);
|
||||
if (!(p.sent.at(-1).button === "left" && p.sent.at(-1).down === false && !p.ctrl.held.size)) fail("release " + JSON.stringify(p.sent));
|
||||
}
|
||||
// Keys held on the Frame are let go too.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlKeyEvent(42, true);
|
||||
p.ctrlRelease(); await tick(10);
|
||||
if (!(p.sent.at(-1).key === 42 && p.sent.at(-1).down === false && !p.ctrl.keys.size)) fail("key release " + JSON.stringify(p.sent));
|
||||
}
|
||||
// A long queue sheds moves and old scrolls, never a release.
|
||||
{
|
||||
const p = page({ api: () => new Promise(() => {}) }); // stuck request
|
||||
p.ctrlSend([{ dx: 1, dy: 1 }]);
|
||||
p.ctrlSend([{ button: "left", down: false }]);
|
||||
for (let i = 0; i < 320; i++) p.ctrlSend([{ scroll: [0, 1] }]);
|
||||
if (!p.ctrl.queue.some(e => e.button === "left" && e.down === false)) fail("trim dropped a release");
|
||||
if (p.ctrl.queue.length > 300) fail("trim kept " + p.ctrl.queue.length);
|
||||
}
|
||||
// Broken on the Frame side: no hammering, and only releases wait.
|
||||
{
|
||||
let calls = 0;
|
||||
const p = page({ api: async () => { calls++; return { state: "error", sent: false }; } });
|
||||
p.ctrlSend([{ button: "left", down: true }, { button: "left", down: false }]);
|
||||
await tick(50);
|
||||
if (calls !== 1) fail("error response reposted " + calls + " times");
|
||||
if (!(p.ctrl.queue.length === 1 && p.ctrl.queue[0].down === false)) fail("error kept " + JSON.stringify(p.ctrl.queue));
|
||||
clearTimeout(p.ctrl.retry);
|
||||
}
|
||||
// Connecting: a tap keeps its position, so it lands where it was made.
|
||||
{
|
||||
const p = page({ api: async () => ({ state: "starting", sent: false }) });
|
||||
p.ctrlTouchDown(p.at(1, 320, 90)); p.ctrlTouchUp(p.at(1, 320, 90));
|
||||
await tick(10);
|
||||
const q = p.ctrl.queue;
|
||||
if (!("fx" in q[0] && q[1].button === "left")) fail("connecting tap " + JSON.stringify(q));
|
||||
clearTimeout(p.ctrl.retry);
|
||||
}
|
||||
// Lifting one of two scrolling fingers doesn't jump the scroll or start a drag.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 100, 200)); p.ctrlTouchDown(p.at(2, 300, 200));
|
||||
p.ctrlTouchUp(p.at(1, 100, 200));
|
||||
p.ctrlTouchMove(p.at(2, 300, 199));
|
||||
p.ctrlTouchUp(p.at(2, 300, 199)); await tick(10);
|
||||
if (p.sent.length) fail("one finger left after scrolling " + JSON.stringify(p.sent));
|
||||
}
|
||||
// A cancelled touch isn't a tap.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 100, 100)); p.ctrlTouchCancel(p.at(1, 100, 100)); await tick(10);
|
||||
if (p.sent.length) fail("cancel clicked " + JSON.stringify(p.sent));
|
||||
}
|
||||
// Press and hold on the bars around the picture does nothing.
|
||||
{
|
||||
const p = page({ rect: { left: 0, top: 0, width: 640, height: 480 } });
|
||||
p.ctrlTouchDown(p.at(1, 320, 10)); await tick(620); p.ctrlTouchUp(p.at(1, 320, 10)); await tick(10);
|
||||
if (p.sent.length) fail("hold on the bars " + JSON.stringify(p.sent));
|
||||
}
|
||||
// Taps only reach the panel in use, and only once its picture is the one on screen.
|
||||
{
|
||||
const fns = ["panelKey", "deskPanel", "ctrlTarget"].map(grab).join("");
|
||||
const check = (desk, live = true) => new Function("desk", "live", "ctrlMode", fns + "; return ctrlTarget();")(desk, live, () => "abs");
|
||||
const a = { display: ":1", window: 5 }, b = { display: ":0", window: 5 };
|
||||
const base = { panels: [a, b], loaded: true, pick: "", focus: ":1/5", shown: ":1/5" };
|
||||
if (!check(base).ok) fail("target: shown and in use");
|
||||
if (check({ ...base, shown: ":0/5" }).ok) fail("target: the picture is another panel with the same id");
|
||||
if (!/Capture or Live/.test(check({ ...base, shown: null }, false).why)) fail("target: stale picture message");
|
||||
if (check({ ...base, pick: ":0/5", shown: ":0/5" }).ok) fail("target: a watched panel that isn't in use");
|
||||
if (check({ ...base, focus: null }).ok) fail("target: nothing in use");
|
||||
}
|
||||
// Focus moves to another panel mid-gesture: the tap or hold does nothing.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 100, 100));
|
||||
p.target.panel = { window: 43, display: ":1" };
|
||||
p.ctrlTouchUp(p.at(1, 100, 100)); await tick(10);
|
||||
p.target.panel = { window: 42, display: ":1" };
|
||||
p.ctrlTouchDown(p.at(1, 100, 100));
|
||||
p.target.panel = null;
|
||||
await tick(620); p.ctrlTouchUp(p.at(1, 100, 100)); await tick(10);
|
||||
if (p.sent.some(e => "button" in e)) fail("gesture outlived its panel " + JSON.stringify(p.sent));
|
||||
}
|
||||
// Trimming keeps a click together with its position.
|
||||
{
|
||||
const p = page({ api: () => new Promise(() => {}) });
|
||||
p.ctrlSend([{ dx: 1, dy: 0 }]); // in flight forever
|
||||
for (let i = 0; i < 100; i++) p.ctrlSend([{ scroll: [0, 1] }]);
|
||||
p.ctrlSend([{ fx: 0.5, fy: 0.5, window: 42, display: ":1" }, { button: "left", down: true }, { button: "left", down: false }]);
|
||||
for (let i = 0; i < 198; i++) p.ctrlSend([{ scroll: [0, 1] }]);
|
||||
const q = p.ctrl.queue, i = q.findIndex(e => e.button === "left" && e.down);
|
||||
if (i < 1 || !("fx" in q[i - 1])) fail("trim split a click from its position");
|
||||
}
|
||||
// Backing off holds for new input too.
|
||||
{
|
||||
let calls = 0;
|
||||
const p = page({ api: async () => { calls++; return { state: "error", sent: false }; } });
|
||||
p.ctrlSend([{ button: "left", down: false }]);
|
||||
await tick(5);
|
||||
for (let i = 0; i < 10; i++) { p.ctrlSend([{ key: 30, down: false }]); await tick(2); }
|
||||
if (calls !== 1) fail("new input bypassed the backoff: " + calls + " requests");
|
||||
clearTimeout(p.ctrl.retry);
|
||||
}
|
||||
// A long paste goes in several requests, so a release never waits behind all of it.
|
||||
{
|
||||
const batches = [];
|
||||
const p = page({ api: async (path, body) => { batches.push(body.events); return { state: "ready", sent: true }; } });
|
||||
p.ctrlText("y".repeat(450));
|
||||
p.ctrlButton("left", false);
|
||||
await tick(30);
|
||||
if (!batches.every(b => b.reduce((a, e) => a + (e.text?.length || 0), 0) <= 100)) fail("a batch carried too much text");
|
||||
if (batches.length < 5) fail("paste went in " + batches.length + " requests");
|
||||
}
|
||||
console.log("control gestures ok");
|
||||
@@ -1,102 +0,0 @@
|
||||
"""Fake-Frame panel responses and the headset page's real HTTP guards."""
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_panels as panels
|
||||
|
||||
# Shape verified with vrcmd on SteamVR 2.18.1 / BUILD_ID 20260925.6191901.
|
||||
OVERLAYS = """---- OVERLAYS ----
|
||||
'valve.steam.desktopgame.12' -- 'Alex's <notes>', 1920x1080 visible VROverlayType_Dashboard_Main
|
||||
'valve.steam.desktopgame.12.thumb' -- 'Thumb', not_visible VROverlayType_Dashboard_Thumbnail
|
||||
'valve.steam.desktopgame.12.layer1' -- 'Layer', visible VROverlayType_Subview
|
||||
'system.pointer' -- 'Pointer', visible VROverlayType_Basic
|
||||
'valve.steam.desktopgame.13' -- 'Other', not_visible VROverlayType_Dashboard_Main
|
||||
"""
|
||||
|
||||
|
||||
class Panels(unittest.TestCase):
|
||||
def test_enumerates_only_main_panels_including_hidden(self):
|
||||
rows = panels.parse_overlays(OVERLAYS)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0]['title'], "Alex's <notes>")
|
||||
self.assertTrue(rows[0]['visible'])
|
||||
self.assertFalse(rows[1]['visible'])
|
||||
|
||||
def test_unavailable_runtime_is_not_an_empty_workspace(self):
|
||||
with self.assertRaises(panels.PanelError):
|
||||
panels.parse_overlays('SteamVR not running')
|
||||
self.assertEqual(panels.parse_overlays('---- OVERLAYS ----'), [])
|
||||
|
||||
@patch.object(panels, 'run', return_value=OVERLAYS)
|
||||
def test_focus_revalidates_and_dispatches_without_shell(self, run):
|
||||
key = 'valve.steam.desktopgame.12'
|
||||
self.assertEqual(panels.focus(key)['requested'], key)
|
||||
self.assertEqual(run.call_args.args[0], [panels.VRCMD, '--showdashboard', key])
|
||||
|
||||
@patch.object(panels, 'run', return_value=OVERLAYS)
|
||||
def test_closed_or_injected_panel_never_dispatches(self, run):
|
||||
for key in (None, 12, 'x;touch /tmp/bad', '../other', 'missing'):
|
||||
with self.assertRaises(panels.PanelError):
|
||||
panels.focus(key)
|
||||
self.assertEqual(run.call_count, 1) # only valid-looking 'missing' enumerates
|
||||
|
||||
|
||||
class HeadsetHTTP(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.server = panels.HTTPServer(('127.0.0.1', 0), panels.Handler)
|
||||
self.server.key = 'test-key'
|
||||
self.server.closing = False
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.thread.join()
|
||||
self.server.server_close()
|
||||
|
||||
def request(self, path, body=None, headers=None):
|
||||
c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
|
||||
try:
|
||||
c.request('POST' if body is not None else 'GET', path, body=body, headers=headers or {})
|
||||
r = c.getresponse()
|
||||
return r.status, r.read().decode()
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def test_page_is_public_but_contains_no_key_or_private_titles(self):
|
||||
status, page = self.request('/')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertNotIn('test-key', page)
|
||||
self.assertIn('textContent=p.title', page) # titles never become HTML
|
||||
self.assertEqual(self.request('/panels')[0], 403)
|
||||
|
||||
@patch.object(panels, 'state', return_value={'panels': []})
|
||||
def test_auth_host_and_origin_checks(self, state):
|
||||
auth = {'X-Panel-Key': 'test-key'}
|
||||
self.assertEqual(self.request('/panels', headers=auth)[0], 200)
|
||||
for extra in ({'Host': 'evil.test'}, {'Origin': 'https://evil.test'}, {'X-Panel-Key': 'wrong'}):
|
||||
self.assertEqual(self.request('/panels', headers={**auth, **extra})[0], 403)
|
||||
self.assertEqual(state.call_count, 1)
|
||||
|
||||
@patch.object(panels, 'focus', return_value={'requested': 'panel'})
|
||||
def test_post_validation_and_close(self, focus):
|
||||
auth = {'X-Panel-Key': 'test-key'}
|
||||
for body in ('[]', '{broken', '0', '"text"', 'x' * 1025):
|
||||
self.assertEqual(self.request('/focus', body, auth)[0], 400)
|
||||
self.assertEqual(focus.call_count, 0)
|
||||
self.assertEqual(self.request('/focus', '{"key":"panel"}', auth)[0], 200)
|
||||
self.assertEqual(focus.call_args.args, ('panel',))
|
||||
self.assertEqual(self.request('/close', '{}', auth)[0], 200)
|
||||
self.assertTrue(self.server.closing)
|
||||
|
||||
@patch.object(panels, 'state', side_effect=panels.PanelError('offline'))
|
||||
def test_offline_is_an_error_not_a_successful_empty_list(self, state):
|
||||
status, body = self.request('/panels', headers={'X-Panel-Key': 'test-key'})
|
||||
self.assertEqual(status, 502)
|
||||
self.assertEqual(json.loads(body), {'error': 'offline'})
|
||||
@@ -1,283 +0,0 @@
|
||||
"""Touch: the live view's Control (ui/frame_touch.py on the Frame, and the server's checks).
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
|
||||
class Mapping(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import frame_touch
|
||||
cls.t = frame_touch
|
||||
|
||||
def test_panel_bigger_than_its_display_is_scaled(self):
|
||||
# Verified 2026-09-29: a 1920x1080 window on :1 (1280x720) took Accept at
|
||||
# window (1828, 1020) as pointer (1219, 680).
|
||||
panel = {"root": [1280, 720], "width": 1920, "height": 1080}
|
||||
x, y = self.t.to_root(panel, 1828 / 1920, 1020 / 1080)
|
||||
self.assertAlmostEqual(x, 1218.7, delta=1)
|
||||
self.assertAlmostEqual(y, 679.4, delta=1)
|
||||
|
||||
def test_same_size_is_one_to_one_and_clamped(self):
|
||||
panel = {"root": [1280, 720], "width": 1280, "height": 720}
|
||||
self.assertEqual(self.t.to_root(panel, 0, 0), (0, 0))
|
||||
self.assertEqual(self.t.to_root(panel, 1, 1), (1279, 719))
|
||||
self.assertEqual(self.t.to_root(panel, -3, 7), (0, 719))
|
||||
|
||||
def test_other_shapes_are_letterboxed(self):
|
||||
panel = {"root": [1280, 720], "width": 800, "height": 800} # square: bars left and right
|
||||
x, y = self.t.to_root(panel, 0, 0.5)
|
||||
self.assertAlmostEqual(x, 280, delta=0.5)
|
||||
self.assertAlmostEqual(y, 359.5, delta=0.5)
|
||||
|
||||
def test_same_id_on_both_displays_is_told_apart_by_pid(self):
|
||||
t = self.t
|
||||
saved = t.displays, t.window_info, t.window_pid
|
||||
self.addCleanup(lambda: (setattr(t, "displays", saved[0]), setattr(t, "window_info", saved[1]),
|
||||
setattr(t, "window_pid", saved[2])))
|
||||
t.displays = lambda: [":0", ":1"]
|
||||
t.window_info = lambda d, w: {"name": f"on {d}", "width": 1280 if w != "root" else 1920, "height": 720}
|
||||
t.window_pid = lambda d, w: {":0": 111, ":1": 222}[d]
|
||||
self.assertEqual(t.locate(5, 222)["display"], ":1")
|
||||
self.assertEqual(t.locate(5, 111)["display"], ":0")
|
||||
self.assertEqual(t.locate(5, None)["display"], ":0")
|
||||
|
||||
def test_focus_display_is_decoded(self):
|
||||
t = self.t
|
||||
saved = t.xprop_root
|
||||
self.addCleanup(lambda: setattr(t, "xprop_root", saved))
|
||||
for values, want in (([12602, 0, 58], ":1"), ([12346], ":0"), ([], None), ([0x41], None)):
|
||||
t.xprop_root = lambda d, n, v=values: v
|
||||
self.assertEqual(t.focus_display(), want)
|
||||
|
||||
def test_ascii_table_covers_printable_characters(self):
|
||||
for code in range(0x20, 0x7F):
|
||||
self.assertIn(chr(code), self.t.ASCII, chr(code))
|
||||
self.assertEqual(self.t.ASCII["a"], (30, False))
|
||||
self.assertEqual(self.t.ASCII["A"], (30, True))
|
||||
self.assertEqual(self.t.ASCII["?"], (53, True))
|
||||
self.assertEqual(self.t.ASCII["1"], (2, False))
|
||||
self.assertEqual(self.t.ASCII["0"], (11, False))
|
||||
|
||||
def test_events_parsing(self):
|
||||
self.assertEqual(self.t.events(b'{"dx": 1}'), [{"dx": 1}])
|
||||
self.assertEqual(self.t.events(b'[{"dx": 1}, 5]'), [{"dx": 1}])
|
||||
self.assertEqual(self.t.events(b"nope"), [])
|
||||
|
||||
|
||||
class FakeGamescope:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __getattr__(self, name):
|
||||
return lambda *a: self.calls.append((name, *a))
|
||||
|
||||
|
||||
class Apply(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import frame_touch
|
||||
cls.t = frame_touch
|
||||
|
||||
def setUp(self):
|
||||
self.focused = {"window": 7, "display": ":1", "root": [1280, 720], "width": 1280, "height": 720, "name": "x"}
|
||||
saved = self.t.focus, self.t.say, self.t.focus_now
|
||||
self.t.STALE[0] = False
|
||||
self.said = []
|
||||
self.t.focus = lambda: dict(self.focused)
|
||||
self.t.focus_now = lambda: (self.focused["window"], self.focused["display"])
|
||||
self.t.say = lambda state, **more: self.said.append((state, more))
|
||||
self.addCleanup(lambda: (setattr(self.t, "focus", saved[0]), setattr(self.t, "say", saved[1]),
|
||||
setattr(self.t, "focus_now", saved[2])))
|
||||
|
||||
def test_tap_moves_then_clicks_in_order(self):
|
||||
gs, panel = FakeGamescope(), None
|
||||
for e in ({"fx": 0.5, "fy": 0.5, "window": 7, "display": ":1"}, {"button": "left", "down": True, "window": 7, "display": ":1"},
|
||||
{"button": "left", "down": False}):
|
||||
panel = self.t.apply(gs, e, panel)
|
||||
self.assertEqual([c[0] for c in gs.calls], ["move_to", "button", "button"])
|
||||
self.assertEqual(gs.calls[1][1:], ("left", True))
|
||||
|
||||
def test_a_tap_meant_for_another_panel_goes_nowhere(self):
|
||||
# Focus moved on: the position and the press are dropped; the release still goes.
|
||||
gs, panel = FakeGamescope(), None
|
||||
for e in ({"fx": 0.5, "fy": 0.5, "window": 99, "display": ":1"}, {"button": "left", "down": True, "window": 99, "display": ":1"},
|
||||
{"key": 30, "down": True, "window": 7, "display": ":0"}, {"button": "left", "down": False, "window": 99, "display": ":1"}):
|
||||
panel = self.t.apply(gs, e, panel)
|
||||
self.assertEqual(gs.calls, [("button", "left", False)])
|
||||
self.assertEqual(self.said[0], ("ready", {"focus": 7, "display": ":1", "stale": True}))
|
||||
|
||||
def test_presses_read_focus_afresh(self):
|
||||
# A move may use a recent reading; a press checks again, so a panel that just
|
||||
# took focus doesn't get a click meant for another.
|
||||
gs, calls = FakeGamescope(), []
|
||||
self.t.focus = lambda: calls.append(1) or dict(self.focused)
|
||||
panel = self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":1"}, None)
|
||||
panel = self.t.apply(gs, {"fx": 0.6, "fy": 0.5, "window": 7, "display": ":1"}, panel)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.t.apply(gs, {"button": "left", "down": True, "window": 7, "display": ":1"}, panel)
|
||||
self.assertEqual(len(calls), 1) # same panel still: the quick check was enough
|
||||
self.focused["window"] = 8
|
||||
self.t.apply(gs, {"button": "left", "down": True, "window": 7, "display": ":1"}, panel)
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertEqual([c[0] for c in gs.calls], ["move_to", "move_to", "button"])
|
||||
|
||||
def test_stale_is_said_once_and_cleared(self):
|
||||
gs, panel = FakeGamescope(), None
|
||||
for e in ({"fx": 0.5, "fy": 0.5, "window": 99, "display": ":1"}, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":1"},
|
||||
{"fx": 0.6, "fy": 0.5, "window": 7, "display": ":1"}):
|
||||
panel = self.t.apply(gs, e, panel)
|
||||
self.assertEqual(self.said, [("ready", {"focus": 7, "display": ":1", "stale": True}),
|
||||
("ready", {"focus": 7, "display": ":1"})])
|
||||
|
||||
def test_stale_clears_on_a_trackpad_move_but_not_on_a_stale_release(self):
|
||||
gs = FakeGamescope()
|
||||
panel = self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 99, "display": ":1"}, None)
|
||||
panel = self.t.apply(gs, {"button": "left", "down": False, "window": 99, "display": ":1"}, panel)
|
||||
self.assertTrue(self.t.STALE[0]) # that release was still aimed at the old panel
|
||||
self.t.apply(gs, {"dx": 3, "dy": 0}, panel)
|
||||
self.assertFalse(self.t.STALE[0])
|
||||
self.assertEqual(self.said[-1][1].get("stale"), None)
|
||||
|
||||
def test_same_window_id_on_the_other_display_is_another_panel(self):
|
||||
gs = FakeGamescope()
|
||||
self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None)
|
||||
self.assertEqual(gs.calls, [])
|
||||
|
||||
def test_relative_scroll_keys_text(self):
|
||||
gs = FakeGamescope()
|
||||
for e in ({"dx": 5, "dy": -3}, {"scroll": [0, 120]}, {"key": 30, "down": True}, {"text": "hi"},
|
||||
{"key": True}, {"button": "sideways"}):
|
||||
self.t.apply(gs, e, None)
|
||||
self.assertEqual([c[0] for c in gs.calls], ["move_by", "scroll", "key", "text"])
|
||||
|
||||
def test_everything_held_is_let_go(self):
|
||||
class Held(self.t.Gamescope):
|
||||
def __init__(self):
|
||||
self.held, self.keys, self.log = set(), set(), []
|
||||
|
||||
def frame(self):
|
||||
pass
|
||||
|
||||
g = Held()
|
||||
g.L = type("L", (), {"ei_device_button_button": lambda *a: g.log.append(("button",) + a[2:]),
|
||||
"ei_device_keyboard_key": lambda *a: g.log.append(("key",) + a[2:])})()
|
||||
g.device = object()
|
||||
g.button("left", True)
|
||||
g.key(42, True)
|
||||
g.release_all()
|
||||
self.assertEqual(g.log[-2:], [("button", 0x110, False), ("key", 42, False)])
|
||||
self.assertEqual((g.held, g.keys), (set(), set()))
|
||||
|
||||
def test_resuming_after_a_pause_lets_go_of_everything(self):
|
||||
t = self.t
|
||||
log, queue = [], [1]
|
||||
|
||||
class L:
|
||||
def __getattr__(self, name):
|
||||
if name == "ei_get_event":
|
||||
return lambda ei: queue.pop(0) if queue else None
|
||||
return {"ei_event_get_type": lambda ev: t.EV_DEVICE_RESUMED, "ei_event_get_device": lambda ev: "dev",
|
||||
"ei_device_has_capability": lambda d, c: True,
|
||||
"ei_device_button_button": lambda d, c, down: log.append(("button", c, down)),
|
||||
"ei_device_keyboard_key": lambda d, c, down: log.append(("key", c, down))}.get(name, lambda *a: 0)
|
||||
|
||||
g = t.Gamescope.__new__(t.Gamescope)
|
||||
g.L, g.ei, g.fd, g.device, g.sequence, g.alive = L(), None, None, None, 0, True
|
||||
g.held, g.keys = {0x110}, {42}
|
||||
g.frame = lambda: None
|
||||
old = t.select.select
|
||||
t.select.select = lambda *a: ([], [], [])
|
||||
try:
|
||||
g.pump()
|
||||
finally:
|
||||
t.select.select = old
|
||||
self.assertEqual(sorted(log), [("button", 0x110, False), ("key", 42, False)])
|
||||
self.assertEqual(g.device, "dev")
|
||||
|
||||
def test_text_uses_shift_for_capitals(self):
|
||||
class Keys(self.t.Gamescope):
|
||||
def __init__(self):
|
||||
self.pressed = []
|
||||
|
||||
def key(self, code, down):
|
||||
self.pressed.append((code, down))
|
||||
k = Keys()
|
||||
k.text("Hié") # the é has no key on a US layout and is left out
|
||||
self.assertEqual(k.pressed, [(42, True), (35, True), (35, False), (42, False), (23, True), (23, False)])
|
||||
|
||||
|
||||
class ServerChecks(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import server
|
||||
cls.s = server
|
||||
|
||||
def test_touch_event_keeps_known_fields(self):
|
||||
ev = self.s.touch_event
|
||||
self.assertEqual(ev({"fx": 0.5, "fy": 2, "window": 5, "display": ":1"}), {"fx": 0.5, "fy": 1.0, "window": 5, "display": ":1"})
|
||||
self.assertEqual(ev({"button": "left", "window": 5, "display": ":0"}), {"button": "left", "down": True, "window": 5, "display": ":0"})
|
||||
self.assertEqual(ev({"button": "right"}), {"button": "right", "down": True})
|
||||
self.assertEqual(ev({"key": 30, "down": False}), {"key": 30, "down": False})
|
||||
self.assertEqual(ev({"scroll": [0, 1e9]}), {"scroll": [0.0, 5000.0]})
|
||||
self.assertEqual(ev({"dx": 3, "other": 1}), {"dx": 3.0})
|
||||
|
||||
def test_touch_event_rejects_bad_ones(self):
|
||||
for bad in (None, {}, {"fx": 0.5, "fy": 0.5}, {"fx": "1", "fy": 0, "window": 1, "display": ":1"}, {"button": "side"},
|
||||
{"fx": 0.5, "fy": 0.5, "window": 1}, {"fx": 0.5, "fy": 0.5, "window": 1, "display": ":1;x"},
|
||||
{"window": 1, "display": ":1"},
|
||||
{"key": 0}, {"key": 999}, {"key": True}, {"scroll": [1]}, {"text": ""}, {"text": "x" * 501},
|
||||
{"fx": 0.1, "fy": 0.1, "window": True, "display": ":1"}):
|
||||
with self.assertRaises(self.s.Failure, msg=repr(bad)):
|
||||
self.s.touch_event(bad)
|
||||
|
||||
def test_panel_stream_is_the_window_and_checked(self):
|
||||
cmd = self.s.stream_command("src=panel&window=10485777&display=:1&h=720&fps=30")
|
||||
self.assertIn("DISPLAY=:1 ffmpeg", cmd)
|
||||
self.assertIn("-window_id 10485777 -i :1", cmd)
|
||||
for bad in ("src=panel&window=1;rm&display=:1", "src=panel&window=1&display=:1;x", "src=panel&display=:1"):
|
||||
with self.assertRaises(self.s.Failure):
|
||||
self.s.stream_command(bad + "&h=720&fps=30")
|
||||
|
||||
def test_touch_agent_runs_the_helper_with_nothing_to_copy(self):
|
||||
agent = self.s.TouchAgent()
|
||||
self.assertEqual(agent.deliver(lambda m: None), "")
|
||||
cmd = agent.command()
|
||||
self.assertTrue(cmd.startswith("python3 -u -c '"))
|
||||
self.assertIn("frame_touch", cmd)
|
||||
|
||||
def test_batch_limit(self):
|
||||
with self.assertRaises(self.s.Failure):
|
||||
self.s.remote_touch({"events": [{"dx": 1}] * (self.s.INPUT_BATCH_LIMIT + 1)})
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "needs node")
|
||||
class PageGestures(unittest.TestCase):
|
||||
"""Control's gestures and queue (tests/page/ctrl_gestures.mjs runs the real functions from index.html)."""
|
||||
|
||||
def test_gestures(self):
|
||||
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "ctrl_gestures.mjs")], capture_output=True, text=True,
|
||||
timeout=60)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
|
||||
|
||||
class Script(unittest.TestCase):
|
||||
def test_helper_compiles_on_the_frames_python(self):
|
||||
# The Frame runs it with its own python3 (3.13 on SteamOS 0.4.1); stdlib and ctypes only.
|
||||
src = (ROOT / "ui/frame_touch.py").read_text()
|
||||
compile(src, "frame_touch.py", "exec")
|
||||
for mod in ("import ctypes", "import json", "import select"):
|
||||
self.assertIn(mod, src)
|
||||
self.assertNotIn("import requests", src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Own VR telemetry, optional catalogue and entitlement guards; no headset needed."""
|
||||
import ctypes
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_status as status
|
||||
import frame_steam as steam
|
||||
import frame_utilities as utilities
|
||||
import frame_vr as vr
|
||||
import server
|
||||
|
||||
|
||||
class Telemetry(unittest.TestCase):
|
||||
def test_background_client_does_not_request_steamvr_start(self):
|
||||
with mock.patch.object(status.C, 'CDLL') as load:
|
||||
with status.OpenVR():
|
||||
pass
|
||||
self.assertEqual(load.return_value.VR_InitInternal2.call_args.args[1], 3)
|
||||
load.return_value.VR_ShutdownInternal.assert_called_once()
|
||||
|
||||
def test_linux_arm64_timing_layout(self):
|
||||
self.assertEqual(ctypes.sizeof(status.Timing), 192)
|
||||
self.assertEqual(status.Timing.pose.offset, 96)
|
||||
|
||||
def test_cpu_excludes_guest_and_counts_iowait_idle(self):
|
||||
with mock.patch.object(status, 'read', return_value='cpu 20 0 10 50 20 0 0 0 7 1\n'):
|
||||
self.assertEqual(status.cpu_ticks(), (100, 70))
|
||||
self.assertEqual(status.cpu_percent((100, 70), (200, 110)), 60)
|
||||
self.assertIsNone(status.cpu_percent((100, 70), (100, 70)))
|
||||
self.assertIsNone(status.cpu_percent(None, (100, 70)))
|
||||
|
||||
def test_compositor_and_app_fps_are_distinct(self):
|
||||
a, b = status.Timing(), status.Timing()
|
||||
a.index, a.time = 100, 10
|
||||
b.index, b.time, b.interval = 118, 10.2, 22.2222
|
||||
b.gpu, b.compositorCpu = 4, 1
|
||||
result = status.timing_values(a, b)
|
||||
self.assertEqual(result['compositorFps'], 90)
|
||||
self.assertEqual(result['appFps'], 45)
|
||||
self.assertEqual(result['frameMs'], 11.11)
|
||||
b.interval = 0
|
||||
self.assertIsNone(status.timing_values(a, b)['appFps'])
|
||||
b.index = a.index
|
||||
self.assertEqual(status.timing_values(a, b), {})
|
||||
b.index = 5 # compositor restart / wrap
|
||||
self.assertEqual(status.timing_values(a, b), {})
|
||||
|
||||
def test_missing_openvr_keeps_sensor_values(self):
|
||||
with mock.patch.object(status, 'OpenVR', side_effect=OSError('missing')), \
|
||||
mock.patch.object(status, 'num', return_value=629), \
|
||||
mock.patch.object(status, 'cpu_ticks', side_effect=[(100, 50), (200, 100)]), \
|
||||
mock.patch.object(status.time, 'sleep'):
|
||||
data = status.performance()
|
||||
self.assertEqual(data['gpuMHz'], 629)
|
||||
self.assertEqual(data['cpuPercent'], 50)
|
||||
self.assertIsNone(data['compositorFps'])
|
||||
self.assertIsNone(data['appFps'])
|
||||
|
||||
def test_import_has_no_device_side_effect(self):
|
||||
out = subprocess.run([sys.executable, '-c', 'import frame_status'],
|
||||
cwd=Path(__file__).resolve().parents[1] / 'ui', capture_output=True, text=True)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
self.assertEqual(out.stdout, '')
|
||||
|
||||
|
||||
class UtilityGuards(unittest.TestCase):
|
||||
def test_all_paid_utilities_refused_before_steam_url(self):
|
||||
with mock.patch.object(steam, 'Page') as page, mock.patch.object(steam, 'steam_url') as url:
|
||||
page.return_value.eval.return_value = [{'id': i, 'owned': False} for i in steam.UTILITY_IDS]
|
||||
for appid in set(steam.UTILITY_IDS) - set(steam.FREE_UTILITIES):
|
||||
with self.assertRaisesRegex(steam.Fail, 'not owned'):
|
||||
steam.install(appid)
|
||||
url.assert_not_called()
|
||||
|
||||
def test_unknown_ownership_fails_closed(self):
|
||||
with mock.patch.object(steam, 'Page') as page, mock.patch.object(steam, 'steam_url') as url:
|
||||
page.return_value.eval.side_effect = steam.Fail('library unavailable')
|
||||
with self.assertRaises(steam.Fail):
|
||||
steam.install(908520)
|
||||
url.assert_not_called()
|
||||
|
||||
def test_owned_software_uses_existing_install_flow(self):
|
||||
with mock.patch.object(steam, 'Page') as page, mock.patch.object(steam, 'steam_url') as url:
|
||||
page.return_value.eval.side_effect = [[{'id': 908520, 'owned': True}], {'name': 'fpsVR', 'installed': True}]
|
||||
self.assertEqual(steam.install(908520)['state'], 'installed')
|
||||
url.assert_not_called()
|
||||
|
||||
def test_catalogue_separates_public_claims_ownership_and_reports(self):
|
||||
data = utilities.catalogue([{'id': 1173510, 'owned': True}], [
|
||||
{'package': 'com.android.app', 'rating': 'works', 'date': '2026-09-28'},
|
||||
{'package': 'steam:908520', 'rating': 'broken', 'date': '2026-09-28', 'notes': 'test report'}])
|
||||
rows = {r['id']: r for r in data['utilities']}
|
||||
self.assertEqual(rows[1173510]['status'], 'untested')
|
||||
self.assertTrue(rows[1173510]['canInstall'])
|
||||
self.assertFalse(rows[908520]['canInstall'])
|
||||
self.assertEqual(rows[908520]['status'], 'broken')
|
||||
self.assertTrue(rows[1494460]['canInstall'])
|
||||
self.assertFalse(rows[1009850]['free'])
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node needed to evaluate Steam JavaScript')
|
||||
def test_ownership_js_includes_software_and_rejects_empty_library(self):
|
||||
script = 'let appStore = {allApps: [{appid:908520,app_type:2,local_per_client_data:{installed:true}}]};\n'
|
||||
script += 'console.log(JSON.stringify(' + steam.UTILITIES_JS + '));'
|
||||
out = subprocess.run(['node', '-e', script], capture_output=True, text=True, check=True)
|
||||
rows = {r['id']: r for r in json.loads(out.stdout)}
|
||||
self.assertTrue(rows[908520]['owned'])
|
||||
self.assertTrue(rows[908520]['installed'])
|
||||
self.assertFalse(rows[1173510]['owned'])
|
||||
out = subprocess.run(['node', '-e', 'let appStore={allApps:[]};' + steam.UTILITIES_JS], capture_output=True)
|
||||
self.assertNotEqual(out.returncode, 0)
|
||||
|
||||
|
||||
class HUD(unittest.TestCase):
|
||||
def test_invalid_action_never_reaches_frame(self):
|
||||
with mock.patch.object(server, 'ssh') as ssh:
|
||||
for action in ('hud-start; reboot', 'adjust', 'recenter', 'restore'):
|
||||
with self.assertRaises(server.Failure) as err:
|
||||
server.vr({'action': action})
|
||||
self.assertEqual(err.exception.status, 400)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_stop_wont_kill_reused_pid(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(vr, 'ROOT', Path(tmp)), \
|
||||
mock.patch.object(vr, 'process_identity', return_value='different'), mock.patch.object(vr.os, 'kill') as kill:
|
||||
(Path(tmp) / 'hud.json').write_text(json.dumps({'pid': 1234, 'start': 'old'}))
|
||||
vr.panel('hud-stop')
|
||||
kill.assert_not_called()
|
||||
self.assertFalse((Path(tmp) / 'hud.json').exists())
|
||||
|
||||
def test_duplicate_start_does_not_spawn(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(vr, 'ROOT', Path(tmp)), \
|
||||
mock.patch.object(vr, 'process_identity', return_value='same'), mock.patch.object(vr.subprocess, 'Popen') as popen:
|
||||
(Path(tmp) / 'hud.json').write_text(json.dumps({'pid': 1234, 'start': 'same'}))
|
||||
self.assertIn('already open', vr.panel('hud-start')['message'])
|
||||
popen.assert_not_called()
|
||||
|
||||
def test_start_failure_cleans_up_process(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(vr, 'ROOT', Path(tmp)), \
|
||||
mock.patch.object(vr.subprocess, 'Popen') as popen, \
|
||||
mock.patch.object(vr.subprocess, 'check_output', side_effect=OSError('no display')):
|
||||
popen.return_value.poll.return_value = None
|
||||
with self.assertRaises(OSError):
|
||||
vr.panel('hud-start')
|
||||
popen.return_value.terminate.assert_called_once()
|
||||
popen.return_value.wait.assert_called_once()
|
||||
@@ -1,261 +0,0 @@
|
||||
"""Panel switcher. Runs on the Frame, either piped over SSH or installed with
|
||||
--open for its loopback-only headset page. Uses Valve's shipped vrcmd and
|
||||
Chromium, not an overlay app. Spatial layout limitations: docs/panels.md.
|
||||
"""
|
||||
import argparse
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
|
||||
PANEL_ID = 2000999030
|
||||
PANEL_KEY = 'valve.steam.desktopgame.' + str(PANEL_ID)
|
||||
KEY = re.compile(r'[A-Za-z0-9_.:-]{1,200}\Z')
|
||||
|
||||
|
||||
class PanelError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def run(args):
|
||||
try:
|
||||
p = subprocess.run(args, capture_output=True, text=True, timeout=10,
|
||||
env={**os.environ, 'DISPLAY': ':0', 'LC_ALL': 'C.UTF-8'})
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
raise PanelError('The panel service did not answer: ' + str(e))
|
||||
if p.returncode:
|
||||
raise PanelError((p.stderr or p.stdout).strip()[-400:] or 'Panel command failed')
|
||||
return p.stdout
|
||||
|
||||
|
||||
def parse_overlays(text):
|
||||
"""Only main dashboard panels, never their thumbnails, layers or cursors.
|
||||
vrcmd output verified on SteamVR 2.18.1, BUILD_ID 20260925.6191901.
|
||||
"""
|
||||
if '---- OVERLAYS ----' not in text:
|
||||
raise PanelError('SteamVR did not return its panel list. Is the headset awake?')
|
||||
panels = []
|
||||
for line in text.splitlines():
|
||||
m = re.fullmatch(r"'([^']+)' -- '(.*)', (.*?) VROverlayType_Dashboard_Main\s*", line)
|
||||
if m and KEY.fullmatch(m[1]):
|
||||
panels.append({'key': m[1], 'title': 'Panel switcher' if m[1] == PANEL_KEY else m[2] or m[1],
|
||||
'visible': 'not_visible' not in m[3]})
|
||||
return panels
|
||||
|
||||
|
||||
def state():
|
||||
return {'panels': parse_overlays(run([VRCMD, '--overlays']))}
|
||||
|
||||
|
||||
def focus(key):
|
||||
if not isinstance(key, str) or not KEY.fullmatch(key):
|
||||
raise PanelError('Choose an open panel.')
|
||||
if key not in {p['key'] for p in state()['panels']}:
|
||||
raise PanelError('That panel has closed. Refresh the list.')
|
||||
run([VRCMD, '--showdashboard', key])
|
||||
# vrcmd acknowledges dispatch, not final focus (a game or the user can
|
||||
# switch again). Do not report a focus success without observing it.
|
||||
return {'requested': key, 'message': 'Asked SteamVR to show the panel.'}
|
||||
|
||||
|
||||
PAGE = '''<!doctype html><html lang="en"><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Panel switcher [fc-panels]</title>
|
||||
<style>body{background:#101b27;color:#eee;font:24px system-ui;margin:36px;max-width:1000px}
|
||||
h1{font-size:36px}button{font:inherit;padding:16px 24px;border:1px solid #546574;border-radius:10px;
|
||||
background:#23384b;color:white;cursor:pointer}button:focus-visible{outline:4px solid #66c0f4}
|
||||
#panels{display:grid;gap:14px;margin:24px 0}#panels button{text-align:left}p{color:#bac8d5}</style>
|
||||
<h1>Panel switcher</h1><p>Choose a panel to show it. Open this panel again from Steam's dashboard.</p>
|
||||
<button id="refresh">Refresh</button> <button id="close">Close switcher</button>
|
||||
<p id="status" role="status"></p><div id="panels"></div>
|
||||
<script>
|
||||
const token=location.hash.slice(1)||sessionStorage.getItem('panelKey')||'';
|
||||
if(token)sessionStorage.setItem('panelKey',token);history.replaceState(null,'',location.pathname);
|
||||
async function api(path,body){const r=await fetch(path,{method:body?'POST':'GET',
|
||||
headers:{'X-Panel-Key':token,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
|
||||
const s=await r.json();if(!r.ok)throw Error(s.error||'Panel request failed');return s;}
|
||||
const status=document.getElementById('status');
|
||||
async function refresh(){try{const s=await api('/panels');const list=document.getElementById('panels');list.replaceChildren();
|
||||
for(const p of s.panels){const b=document.createElement('button');b.textContent=p.title;
|
||||
b.onclick=async()=>{b.disabled=true;try{const r=await api('/focus',{key:p.key});status.textContent=r.message;}
|
||||
catch(e){status.textContent=e.message;}finally{b.disabled=false;}};list.append(b);}
|
||||
status.textContent=s.panels.length?'':'No open panels.';}catch(e){status.textContent=e.message;}}
|
||||
document.getElementById('refresh').onclick=refresh;
|
||||
document.getElementById('close').onclick=async()=>{try{await api('/close',{});window.close();}catch(e){status.textContent=e.message;}};
|
||||
refresh();
|
||||
</script></html>'''
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def setup(self):
|
||||
super().setup()
|
||||
self.connection.settimeout(5)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass # never log the page's access key
|
||||
|
||||
def reply(self, code, value, html=False):
|
||||
data = value.encode() if html else json.dumps(value).encode()
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8' if html else 'application/json')
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('X-Content-Type-Options', 'nosniff')
|
||||
self.send_header('Referrer-Policy', 'no-referrer')
|
||||
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def allowed(self):
|
||||
host = '127.0.0.1:' + str(self.server.server_port)
|
||||
origin = self.headers.get('Origin')
|
||||
return (self.headers.get('Host') == host and
|
||||
(origin is None or origin == 'http://' + host) and
|
||||
hmac.compare_digest(self.headers.get('X-Panel-Key', '').encode(), self.server.key.encode()))
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == '/':
|
||||
return self.reply(200, PAGE, html=True) # no data or access key in the page
|
||||
if not self.allowed():
|
||||
return self.reply(403, {'error': 'Open the switcher from Frame Control.'})
|
||||
try:
|
||||
if self.path == '/panels':
|
||||
return self.reply(200, state())
|
||||
self.reply(404, {'error': 'Not found'})
|
||||
except PanelError as e:
|
||||
self.reply(502, {'error': str(e)})
|
||||
|
||||
def do_POST(self):
|
||||
if not self.allowed():
|
||||
return self.reply(403, {'error': 'Forbidden'})
|
||||
try:
|
||||
size = int(self.headers.get('Content-Length', '0'))
|
||||
if not 0 < size <= 1024:
|
||||
raise ValueError('Invalid request size')
|
||||
body = json.loads(self.rfile.read(size))
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError('Expected an object')
|
||||
if self.path == '/focus':
|
||||
return self.reply(200, focus(body.get('key')))
|
||||
if self.path == '/close':
|
||||
self.server.closing = True
|
||||
return self.reply(200, {'closed': True})
|
||||
self.reply(404, {'error': 'Not found'})
|
||||
except (ValueError, PanelError) as e:
|
||||
self.reply(400, {'error': str(e)})
|
||||
|
||||
|
||||
def serve():
|
||||
"""Own only our Chromium profile and process group. No changes to Steam,
|
||||
SteamVR, other Chromium sessions, or global power settings.
|
||||
"""
|
||||
import fcntl # only on the Frame; module/tests also import on Windows
|
||||
folder = Path.home() / '.local/share/frame-control/panels'
|
||||
folder.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with (folder / 'lock').open('w') as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
print(json.dumps(focus(PANEL_KEY)), flush=True)
|
||||
return
|
||||
chrome = Path.home() / 'chromium-xr/chrome'
|
||||
if chrome.is_file():
|
||||
command = [str(chrome)]
|
||||
profile = folder / 'chromium'
|
||||
elif Path('/usr/bin/chromium').is_file():
|
||||
command = ['/usr/bin/chromium']
|
||||
profile = folder / 'chromium'
|
||||
elif subprocess.run(['flatpak', 'info', 'org.chromium.Chromium'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0:
|
||||
command = ['flatpak', 'run', 'org.chromium.Chromium']
|
||||
profile = Path.home() / '.var/app/org.chromium.Chromium/data/frame-panel-switcher'
|
||||
else:
|
||||
raise PanelError('The headset switcher needs Chromium. The companion switcher still works.')
|
||||
server = HTTPServer(('127.0.0.1', 0), Handler)
|
||||
server.key = secrets.token_urlsafe(32)
|
||||
server.closing = False
|
||||
server.timeout = .5
|
||||
url = 'http://127.0.0.1:%d/#%s' % (server.server_port, server.key)
|
||||
env = {**os.environ, 'DISPLAY': ':0'}
|
||||
env.pop('WAYLAND_DISPLAY', None)
|
||||
browser = subprocess.Popen([*command, '--ozone-platform=x11',
|
||||
'--user-data-dir=' + str(profile),
|
||||
'--no-first-run', '--no-default-browser-check',
|
||||
'--password-store=basic', '--window-size=1200,800', '--app=' + url],
|
||||
env=env, start_new_session=True, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
def stop(signum, frame):
|
||||
server.closing = True
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
win = None
|
||||
try:
|
||||
deadline = time.monotonic() + 30
|
||||
while not win and time.monotonic() < deadline and browser.poll() is None:
|
||||
server.handle_request() # Chromium must fetch the page before it has a title
|
||||
for line in run(['xwininfo', '-root', '-children']).splitlines():
|
||||
m = re.match(r'\s*(0x[0-9a-fA-F]+) .*\[fc-panels\]', line)
|
||||
if m:
|
||||
win = m[1]
|
||||
break
|
||||
if not win:
|
||||
raise PanelError('The switcher window did not appear within 30 seconds.')
|
||||
run(['xprop', '-id', win, '-f', 'STEAM_GAME', '32c', '-set', 'STEAM_GAME', str(PANEL_ID)])
|
||||
print(json.dumps({'message': 'Opened the panel switcher in the headset.'}), flush=True)
|
||||
# Caller reads exactly one line, then disconnects; no more stdout.
|
||||
while not server.closing and browser.poll() is None:
|
||||
server.handle_request()
|
||||
# Closing the last app window need not exit Chromium.
|
||||
if win not in run(['xwininfo', '-root', '-children']):
|
||||
break
|
||||
finally:
|
||||
server.server_close()
|
||||
if browser.poll() is None:
|
||||
os.killpg(browser.pid, signal.SIGTERM)
|
||||
try:
|
||||
browser.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(browser.pid, signal.SIGKILL)
|
||||
browser.wait()
|
||||
|
||||
|
||||
def open_switcher():
|
||||
# This command runs from an installed path, never from the SSH stdin copy.
|
||||
proc = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--serve'],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
text=True, start_new_session=True)
|
||||
line = proc.stdout.readline()
|
||||
proc.stdout.close()
|
||||
if not line:
|
||||
raise PanelError('The headset switcher could not start.')
|
||||
result = json.loads(line)
|
||||
if 'error' in result:
|
||||
raise PanelError(result['error'])
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--focus')
|
||||
parser.add_argument('--open', action='store_true')
|
||||
parser.add_argument('--serve', action='store_true')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.serve:
|
||||
serve()
|
||||
else:
|
||||
print(json.dumps(open_switcher() if args.open else focus(args.focus) if args.focus else state()))
|
||||
except (PanelError, OSError) as e:
|
||||
print(json.dumps({'error': str(e)}), flush=True)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+4
-104
@@ -1,12 +1,9 @@
|
||||
"""Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object.
|
||||
|
||||
Read-only. Every probe is best effort: a missing tool or file gives null, not an
|
||||
error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1
|
||||
(build 20260925.6191901). See docs/vr-utilities.md.
|
||||
error. Paths verified on SteamOS 0.3.0 (vr), build 20260922.
|
||||
"""
|
||||
import ctypes as C
|
||||
import glob
|
||||
import math
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -186,104 +183,11 @@ def activity_level():
|
||||
return None
|
||||
|
||||
|
||||
# OpenVR C ABI, ValveSoftware/openvr headers/openvr_capi.h (IVRCompositor_029).
|
||||
# Exact interface version: never guess an index against a different table.
|
||||
class Pose(C.Structure):
|
||||
_fields_ = [('matrix', C.c_float * 12), ('velocity', C.c_float * 3),
|
||||
('angular', C.c_float * 3), ('result', C.c_int),
|
||||
('valid', C.c_bool), ('connected', C.c_bool)]
|
||||
|
||||
|
||||
class Timing(C.Structure):
|
||||
_fields_ = [(n, C.c_uint32) for n in ('size', 'index', 'presents', 'mis', 'dropped', 'flags')] + [
|
||||
('time', C.c_double)] + [(n, C.c_float) for n in (
|
||||
'gpuPre', 'gpuPost', 'gpu', 'compositorGpu', 'compositorCpu', 'idleCpu', 'interval',
|
||||
'presentCpu', 'waitCpu', 'submit', 'posesCalled', 'posesReady', 'frameReady',
|
||||
'updateStart', 'updateEnd', 'renderStart')] + [
|
||||
('pose', Pose), ('ready', C.c_uint32), ('first', C.c_uint32), ('transfer', C.c_float)]
|
||||
|
||||
|
||||
class OpenVR:
|
||||
def __enter__(self):
|
||||
self.lib = C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
|
||||
self.lib.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
|
||||
self.lib.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
|
||||
self.lib.VR_GetGenericInterface.restype = C.c_void_p
|
||||
err = C.c_int()
|
||||
self.lib.VR_InitInternal2(C.byref(err), 3, None) # background: never start or keep SteamVR running
|
||||
if err.value:
|
||||
raise RuntimeError(f'SteamVR unavailable (init {err.value})')
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.lib.VR_ShutdownInternal()
|
||||
|
||||
def function(self, interface, index, result, *args):
|
||||
err = C.c_int()
|
||||
ptr = self.lib.VR_GetGenericInterface(('FnTable:' + interface).encode(), C.byref(err))
|
||||
if err.value or not ptr:
|
||||
raise RuntimeError(f'{interface} unavailable ({err.value})')
|
||||
return C.CFUNCTYPE(result, *args)(C.cast(ptr, C.POINTER(C.c_void_p))[index])
|
||||
|
||||
|
||||
def cpu_ticks():
|
||||
line = (read('/proc/stat') or '').splitlines()
|
||||
if not line or not line[0].startswith('cpu '):
|
||||
return None
|
||||
try:
|
||||
# guest/guest_nice are already included in user/nice.
|
||||
ticks = [int(x) for x in line[0].split()[1:9]]
|
||||
return sum(ticks), ticks[3] + ticks[4]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def cpu_percent(before, after):
|
||||
if before is None or after is None or after[0] <= before[0]:
|
||||
return None
|
||||
return round(max(0, min(100, 100 * (1 - (after[1] - before[1]) / (after[0] - before[0])))), 1)
|
||||
|
||||
|
||||
def positive(value):
|
||||
return round(value, 2) if math.isfinite(value) and value > 0 else None
|
||||
|
||||
|
||||
def timing_values(before, after):
|
||||
dt, frames = after.time - before.time, after.index - before.index
|
||||
if dt <= 0 or frames <= 0 or frames / dt > 1000:
|
||||
return {} # standby or old data; never present stale timings as live
|
||||
return {'compositorFps': positive(frames / dt),
|
||||
'frameMs': positive(1000 * dt / frames),
|
||||
'appFps': positive(1000 / after.interval) if after.interval > 0 else None,
|
||||
'gpuMs': positive(after.gpu), 'compositorCpuMs': positive(after.compositorCpu)}
|
||||
|
||||
|
||||
def performance():
|
||||
out = {'compositorFps': None, 'frameMs': None, 'appFps': None,
|
||||
'gpuMs': None, 'compositorCpuMs': None, 'cpuPercent': None,
|
||||
'gpuMHz': num('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6)}
|
||||
before = cpu_ticks()
|
||||
try:
|
||||
with OpenVR() as vr:
|
||||
get = vr.function('IVRCompositor_029', 10, C.c_bool, C.POINTER(Timing), C.c_uint32)
|
||||
a, b = Timing(), Timing()
|
||||
a.size = b.size = C.sizeof(Timing)
|
||||
first = get(C.byref(a), 0)
|
||||
time.sleep(0.2)
|
||||
if first and get(C.byref(b), 0):
|
||||
out.update(timing_values(a, b))
|
||||
except (OSError, RuntimeError, AttributeError): # AttributeError: a SteamVR build without these exports
|
||||
time.sleep(0.2)
|
||||
out['cpuPercent'] = cpu_percent(before, cpu_ticks())
|
||||
return out
|
||||
|
||||
|
||||
def status():
|
||||
def main():
|
||||
uptime = read("/proc/uptime")
|
||||
procs = process_names()
|
||||
return {
|
||||
print(json.dumps({
|
||||
"time": time.time(),
|
||||
"performance": performance(),
|
||||
"hostname": socket.gethostname(),
|
||||
"os": os_release(),
|
||||
"uptime": float(uptime.split()[0]) if uptime else None,
|
||||
@@ -303,11 +207,7 @@ def status():
|
||||
},
|
||||
"games": games(),
|
||||
"flatpaks": flatpaks(),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print(json.dumps(status()))
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -141,21 +141,6 @@ OWNED_JS = r"""
|
||||
})()
|
||||
"""
|
||||
|
||||
# Software has app_type 2, so utility ownership must not use OWNED_JS's games filter.
|
||||
# Steam prices checked 2026-09-28: OVR Advanced Settings is paid on Steam too.
|
||||
UTILITY_IDS = (1009850, 1173510, 1068820, 908520, 1494460)
|
||||
FREE_UTILITIES = (1494460,)
|
||||
UTILITIES_JS = """(() => {
|
||||
const apps = appStore.allApps;
|
||||
if (!apps || !apps.length) throw new Error("Steam library is not loaded; ownership is unknown");
|
||||
return [1009850,1173510,1068820,908520,1494460].map(id => {
|
||||
const a = apps.find(a => a.appid === id);
|
||||
return {id, owned: !!a, installed: !!a?.local_per_client_data?.installed,
|
||||
frame: a ? (a.steam_hw_compat_category_packed >> 8) & 3 : 0};
|
||||
});
|
||||
})()"""
|
||||
|
||||
|
||||
WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({
|
||||
state: i?.eInstallState ?? 0, app: i?.currentAppID ?? 0, need: i?.nDiskSpaceRequired || 0,
|
||||
free: i?.nDiskSpaceAvailable || 0, error: i?.eAppError, detail: i?.errorDetail }))"""
|
||||
@@ -173,10 +158,6 @@ def owned():
|
||||
|
||||
def install(appid):
|
||||
page = Page()
|
||||
if appid in UTILITY_IDS and appid not in FREE_UTILITIES:
|
||||
rows = page.eval(UTILITIES_JS)
|
||||
if not any(r['id'] == appid and r['owned'] for r in rows):
|
||||
raise Fail("This paid utility is not owned by the Frame account. No install or store action was taken.")
|
||||
app = page.eval(f"(a => a && {{name: a.display_name, installed: !!a.local_per_client_data?.installed}})"
|
||||
f"(appStore.GetAppOverviewByAppID({appid}))")
|
||||
if app and app["installed"]:
|
||||
@@ -235,8 +216,6 @@ def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if cmd == "owned":
|
||||
out = owned()
|
||||
elif cmd == "utilities":
|
||||
out = {"utilities": Page().eval(UTILITIES_JS)}
|
||||
elif cmd in ("install", "store") and len(sys.argv) == 3 and sys.argv[2].isdigit():
|
||||
out = (install if cmd == "install" else store)(int(sys.argv[2]))
|
||||
else:
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
"""Touch and direct input for the Steam Frame's panels. Frame Control's server runs this ON the Frame.
|
||||
|
||||
gamescope, the Frame's compositor, serves Valve's own input injection: an EIS
|
||||
socket (libei's server side), which Steam uses to feed it Remote Play input.
|
||||
This connects to it with libei, which is on the SteamOS image, and points,
|
||||
clicks, scrolls and types into the panel that has focus in the headset: the
|
||||
one the wearer last used. No install, and it reaches every panel, on either
|
||||
of gamescope's X displays (see docs/streaming.md).
|
||||
|
||||
python3 frame_touch.py focus print the focused panel as JSON
|
||||
python3 frame_touch.py panels print every app panel as JSON, and which has focus
|
||||
python3 frame_touch.py read events on stdin, one JSON object (or list) per line:
|
||||
{"fx": 0.5, "fy": 0.2, "window": 123, "display": ":1"}
|
||||
pointer to that fraction of that panel; any event can
|
||||
name its panel, and goes nowhere if another has focus
|
||||
{"dx": 4, "dy": -2} pointer by that much
|
||||
{"button": "left", "down": true} left, right or middle; "down" false releases
|
||||
{"scroll": [0, 120]} by pixels; positive y scrolls down
|
||||
{"key": 30, "down": true} a Linux (evdev) key code, as the page maps KeyboardEvent.code
|
||||
{"text": "hello"} printable ASCII, typed on a US layout
|
||||
|
||||
Status goes to stdout, one JSON object per line: {"state": "ready" | "error", ...}.
|
||||
Standard library only (ctypes for libei), like the rest of what runs on the Frame.
|
||||
"""
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
SOCKET = "/run/user/{uid}/gamescope-0-ei" # filled in on the Frame (Windows has no getuid; the tests import this)
|
||||
BUTTONS = {"left": 0x110, "right": 0x111, "middle": 0x112} # BTN_LEFT, BTN_RIGHT, BTN_MIDDLE
|
||||
SHIFT = 42 # KEY_LEFTSHIFT
|
||||
# Printable ASCII on a US layout: character -> (evdev key code, shifted).
|
||||
ROWS = [("1234567890-=", "!@#$%^&*()_+", 2), ("qwertyuiop[]", "QWERTYUIOP{}", 16),
|
||||
("asdfghjkl;'`", 'ASDFGHJKL:"~', 30), ("\\zxcvbnm,./", "|ZXCVBNM<>?", 43)]
|
||||
ASCII = {" ": (57, False), "\n": (28, False), "\t": (15, False)}
|
||||
for plain, shifted, first in ROWS:
|
||||
for i, (a, b) in enumerate(zip(plain, shifted)):
|
||||
ASCII[a], ASCII[b] = (first + i, False), (first + i, True)
|
||||
|
||||
# libei's event types and device capabilities (libei.h, libei 1.4).
|
||||
EV_CONNECT, EV_DISCONNECT, EV_SEAT_ADDED, EV_DEVICE_ADDED, EV_DEVICE_REMOVED = 1, 2, 3, 5, 6
|
||||
EV_DEVICE_PAUSED, EV_DEVICE_RESUMED = 7, 8
|
||||
CAP_POINTER, CAP_ABSOLUTE, CAP_KEYBOARD, CAP_SCROLL, CAP_BUTTON = 1, 2, 4, 16, 32
|
||||
|
||||
|
||||
def say(state, **more):
|
||||
print(json.dumps({"state": state, **more}), flush=True)
|
||||
|
||||
|
||||
# ---- which panel has focus -----------------------------------------------------
|
||||
|
||||
def xprop_root(display, name):
|
||||
try:
|
||||
out = subprocess.run(["xprop", "-root", name], env=dict(os.environ, DISPLAY=display),
|
||||
capture_output=True, text=True, timeout=5).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
values = out.split("=", 1)[1] if "=" in out else ""
|
||||
return [int(v) for v in values.replace(",", " ").split() if v.isdigit()]
|
||||
|
||||
|
||||
def window_info(display, window):
|
||||
"""Name and geometry of a window on one X display, or None if it isn't there."""
|
||||
try:
|
||||
which = ["-root"] if window == "root" else ["-id", str(window)]
|
||||
out = subprocess.run(["xwininfo", *which], env=dict(os.environ, DISPLAY=display),
|
||||
capture_output=True, text=True, timeout=5).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if "IsViewable" not in out:
|
||||
return None
|
||||
info = {}
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("xwininfo: Window id:"):
|
||||
info["name"] = line.split('"', 1)[1].rsplit('"', 1)[0] if '"' in line else ""
|
||||
for key, field in (("Absolute upper-left X:", "x"), ("Absolute upper-left Y:", "y"),
|
||||
("Width:", "width"), ("Height:", "height")):
|
||||
if line.startswith(key):
|
||||
info[field] = int(line.split(":", 1)[1])
|
||||
return info if "width" in info else None
|
||||
|
||||
|
||||
def displays():
|
||||
return sorted(f":{n[1:]}" for n in os.listdir("/tmp/.X11-unix") if n[1:].isdigit())
|
||||
|
||||
|
||||
def window_pid(display, window):
|
||||
try:
|
||||
out = subprocess.run(["xprop", "-id", str(window), "_NET_WM_PID"], env=dict(os.environ, DISPLAY=display),
|
||||
capture_output=True, text=True, timeout=5).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
value = out.rsplit("=", 1)[-1].strip() if "=" in out else ""
|
||||
return int(value) if value.isdigit() else None
|
||||
|
||||
|
||||
def locate(window, pid):
|
||||
"""The display a focusable window is on, with its name and geometry.
|
||||
|
||||
Window ids are per X server, so :0 and :1 can both have one; the pid gamescope
|
||||
lists with it (GAMESCOPE_FOCUSABLE_WINDOWS) tells them apart.
|
||||
"""
|
||||
found = []
|
||||
for display in displays():
|
||||
info = window_info(display, window)
|
||||
if info:
|
||||
found.append((display, info))
|
||||
if len(found) > 1 and pid:
|
||||
found = [f for f in found if window_pid(f[0], window) == pid] or found
|
||||
if not found:
|
||||
return None
|
||||
display, info = found[0]
|
||||
root = window_info(display, "root") or {}
|
||||
return {"window": window, "display": display, **info,
|
||||
"root": [root.get("width", info["width"]), root.get("height", info["height"])]}
|
||||
|
||||
|
||||
def focusable():
|
||||
"""gamescope's focusable windows as (window, app id, pid)."""
|
||||
t = xprop_root(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")
|
||||
return [tuple(t[i:i + 3]) for i in range(0, len(t) - 2, 3)]
|
||||
|
||||
|
||||
def focus_display():
|
||||
"""The display of the focused window, from GAMESCOPE_FOCUS_DISPLAY on :0's root.
|
||||
|
||||
gamescope writes the name (":1") as 32-bit items, so its first four bytes land,
|
||||
little-endian, in the first value: 12602 is 0x313A, ":1" (steamcompmgr.cpp;
|
||||
seen 2026-09-29).
|
||||
"""
|
||||
values = xprop_root(":0", "GAMESCOPE_FOCUS_DISPLAY")
|
||||
if not values:
|
||||
return None
|
||||
name = (values[0] & 0xFFFFFFFF).to_bytes(4, "little").split(b"\0", 1)[0].decode("ascii", "replace")
|
||||
return name if name[:1] == ":" and name[1:].isdigit() else None
|
||||
|
||||
|
||||
def focus_now():
|
||||
"""Just which window and display have focus: two property reads, for checking a press."""
|
||||
window = (xprop_root(":0", "GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
|
||||
return (window or None, focus_display() if window else None)
|
||||
|
||||
|
||||
def focus():
|
||||
"""The panel that has focus in the headset: window, display, name and sizes (gamescope
|
||||
publishes the window and its display on :0's root)."""
|
||||
window = (xprop_root(":0", "GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
|
||||
if not window:
|
||||
return {"window": None}
|
||||
app, pid = next(((a, p) for w, a, p in focusable() if w == window), (None, None))
|
||||
display = focus_display()
|
||||
info = window_info(display, window) if display else None
|
||||
if info:
|
||||
root = window_info(display, "root") or {}
|
||||
panel = {"window": window, "display": display, **info,
|
||||
"root": [root.get("width", info["width"]), root.get("height", info["height"])]}
|
||||
else:
|
||||
panel = locate(window, pid) # no display published: tell them apart by pid
|
||||
return {**panel, "app": app} if panel else {"window": None}
|
||||
|
||||
|
||||
def panels():
|
||||
"""Every app panel (gamescope's focusable windows), for watching one that hasn't focus."""
|
||||
now = focus()
|
||||
found = []
|
||||
for window, app, pid in focusable():
|
||||
panel = locate(window, pid)
|
||||
if panel and panel["width"] > 1 and panel["height"] > 1 and \
|
||||
not any(f["window"] == window and f["display"] == panel["display"] for f in found):
|
||||
panel.pop("root", None)
|
||||
found.append({**panel, "app": app, "focused": (window, panel["display"]) ==
|
||||
(now.get("window"), now.get("display"))})
|
||||
return {"focus": now.get("window"), "focus_display": now.get("display"), "panels": found}
|
||||
|
||||
|
||||
def to_root(panel, fx, fy):
|
||||
"""A point given as a fraction of the panel, in the root coordinates gamescope's pointer uses.
|
||||
|
||||
gamescope fits each panel's window to its display, so a 1920x1080 window on a
|
||||
1280x720 display takes pointer positions at two thirds scale (verified 2026-09-29).
|
||||
"""
|
||||
rw, rh = panel["root"]
|
||||
w, h = panel["width"], panel["height"]
|
||||
s = min(rw / w, rh / h)
|
||||
ox, oy = (rw - w * s) / 2, (rh - h * s) / 2
|
||||
fx, fy = min(max(fx, 0.0), 1.0), min(max(fy, 0.0), 1.0)
|
||||
return ox + fx * (w * s - 1), oy + fy * (h * s - 1)
|
||||
|
||||
|
||||
# ---- gamescope's input socket ----------------------------------------------------
|
||||
|
||||
def libei():
|
||||
L = ctypes.CDLL("libei.so.1")
|
||||
vp, c = ctypes.c_void_p, ctypes
|
||||
sig = {
|
||||
"ei_new_sender": (vp, [vp]), "ei_configure_name": (None, [vp, c.c_char_p]),
|
||||
"ei_setup_backend_socket": (c.c_int, [vp, c.c_char_p]), "ei_get_fd": (c.c_int, [vp]),
|
||||
"ei_dispatch": (None, [vp]), "ei_get_event": (vp, [vp]), "ei_event_get_type": (c.c_int, [vp]),
|
||||
"ei_event_unref": (vp, [vp]), "ei_event_get_seat": (vp, [vp]), "ei_event_get_device": (vp, [vp]),
|
||||
"ei_device_has_capability": (c.c_bool, [vp, c.c_int]), "ei_now": (c.c_uint64, [vp]),
|
||||
"ei_device_start_emulating": (None, [vp, c.c_uint32]), "ei_device_stop_emulating": (None, [vp]),
|
||||
"ei_device_frame": (None, [vp, c.c_uint64]),
|
||||
"ei_device_pointer_motion": (None, [vp, c.c_double, c.c_double]),
|
||||
"ei_device_pointer_motion_absolute": (None, [vp, c.c_double, c.c_double]),
|
||||
"ei_device_button_button": (None, [vp, c.c_uint32, c.c_bool]),
|
||||
"ei_device_scroll_delta": (None, [vp, c.c_double, c.c_double]),
|
||||
"ei_device_keyboard_key": (None, [vp, c.c_uint32, c.c_bool]),
|
||||
"ei_unref": (vp, [vp]),
|
||||
}
|
||||
for name, (res, args) in sig.items():
|
||||
f = getattr(L, name)
|
||||
f.restype, f.argtypes = res, args
|
||||
return L
|
||||
|
||||
|
||||
class Gamescope:
|
||||
"""One connection to gamescope's EIS socket and its virtual input device."""
|
||||
|
||||
def __init__(self):
|
||||
self.L = L = libei()
|
||||
self.ei = L.ei_new_sender(None)
|
||||
L.ei_configure_name(self.ei, b"Frame Control")
|
||||
if L.ei_setup_backend_socket(self.ei, SOCKET.format(uid=os.getuid()).encode()) != 0:
|
||||
raise RuntimeError("Couldn't reach gamescope's input socket. Is the headset on?")
|
||||
self.fd = L.ei_get_fd(self.ei)
|
||||
self.device, self.sequence, self.held, self.keys, self.alive = None, 0, set(), set(), True
|
||||
|
||||
def pump(self, wait=0.0):
|
||||
"""Handle gamescope's events; False once it has disconnected."""
|
||||
select.select([self.fd], [], [], wait)
|
||||
self.L.ei_dispatch(self.ei)
|
||||
alive = True
|
||||
while True:
|
||||
ev = self.L.ei_get_event(self.ei)
|
||||
if not ev:
|
||||
return alive
|
||||
kind = self.L.ei_event_get_type(ev)
|
||||
if kind == EV_SEAT_ADDED:
|
||||
seat = self.L.ei_event_get_seat(ev)
|
||||
# Variadic, ending in 0 (NULL): ask for everything we send.
|
||||
self.L.ei_seat_bind_capabilities(ctypes.c_void_p(seat), *map(ctypes.c_int, (
|
||||
CAP_POINTER, CAP_ABSOLUTE, CAP_BUTTON, CAP_SCROLL, CAP_KEYBOARD, 0)))
|
||||
elif kind == EV_DEVICE_RESUMED:
|
||||
device = self.L.ei_event_get_device(ev)
|
||||
if self.L.ei_device_has_capability(device, CAP_ABSOLUTE):
|
||||
self.sequence += 1
|
||||
self.L.ei_device_start_emulating(device, self.sequence)
|
||||
self.device = device
|
||||
# Releases that arrived while it was paused were dropped: let go of
|
||||
# everything now, so the headset and this agent agree nothing is held.
|
||||
if self.held or self.keys:
|
||||
self.release_all()
|
||||
elif kind in (EV_DEVICE_PAUSED, EV_DEVICE_REMOVED):
|
||||
if self.L.ei_event_get_device(ev) == self.device:
|
||||
self.device = None
|
||||
elif kind == EV_DISCONNECT:
|
||||
self.device, alive, self.alive = None, False, False
|
||||
self.L.ei_event_unref(ev)
|
||||
|
||||
def wait_ready(self, timeout=5):
|
||||
end = time.time() + timeout
|
||||
while self.device is None and time.time() < end:
|
||||
if not self.pump(0.1):
|
||||
break
|
||||
if self.device is None:
|
||||
raise RuntimeError("gamescope closed its input socket" if not self.alive
|
||||
else "gamescope didn't offer an input device")
|
||||
|
||||
def frame(self):
|
||||
self.L.ei_device_frame(self.device, self.L.ei_now(self.ei))
|
||||
self.L.ei_dispatch(self.ei)
|
||||
|
||||
def move_to(self, x, y):
|
||||
self.L.ei_device_pointer_motion_absolute(self.device, x, y)
|
||||
self.frame()
|
||||
|
||||
def move_by(self, dx, dy):
|
||||
self.L.ei_device_pointer_motion(self.device, dx, dy)
|
||||
self.frame()
|
||||
|
||||
def button(self, name, down):
|
||||
code = BUTTONS[name]
|
||||
if down == (code in self.held):
|
||||
return # already in that state
|
||||
self.L.ei_device_button_button(self.device, code, down)
|
||||
self.frame()
|
||||
(self.held.add if down else self.held.discard)(code)
|
||||
|
||||
def scroll(self, dx, dy):
|
||||
self.L.ei_device_scroll_delta(self.device, dx, dy)
|
||||
self.frame()
|
||||
|
||||
def key(self, code, down):
|
||||
self.L.ei_device_keyboard_key(self.device, code, down)
|
||||
self.frame()
|
||||
(self.keys.add if down else self.keys.discard)(code)
|
||||
# Paced: a burst of keys can reach the app out of order (seen 2026-09-29).
|
||||
time.sleep(0.008)
|
||||
|
||||
def text(self, text):
|
||||
for ch in text:
|
||||
if ch not in ASCII:
|
||||
continue
|
||||
code, shifted = ASCII[ch]
|
||||
if shifted:
|
||||
self.key(SHIFT, True)
|
||||
self.key(code, True)
|
||||
self.key(code, False)
|
||||
if shifted:
|
||||
self.key(SHIFT, False)
|
||||
|
||||
def release_all(self):
|
||||
"""Let go of every button and key still down, so nothing stays held in the headset."""
|
||||
for code in list(self.held):
|
||||
name = next(n for n, c in BUTTONS.items() if c == code)
|
||||
self.button(name, False)
|
||||
for code in list(self.keys):
|
||||
self.key(code, False)
|
||||
|
||||
|
||||
# ---- events from the server --------------------------------------------------------
|
||||
|
||||
def events(line):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except ValueError:
|
||||
return []
|
||||
return [e for e in (data if isinstance(data, list) else [data]) if isinstance(e, dict)]
|
||||
|
||||
|
||||
def number(value, limit=100000.0):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
|
||||
raise ValueError("not a number")
|
||||
return max(-limit, min(limit, float(value)))
|
||||
|
||||
|
||||
STALE = [False] # whether the last status said a tap went nowhere
|
||||
|
||||
|
||||
def aimed_elsewhere(event, panel):
|
||||
"""Whether an event names a panel that isn't the one with focus now."""
|
||||
if "window" not in event:
|
||||
return False
|
||||
return (panel.get("window"), panel.get("display")) != (event.get("window"), event.get("display"))
|
||||
|
||||
|
||||
def apply(gs, event, panel):
|
||||
"""Send one event; returns the focused panel it checked against (looked up at most once a second).
|
||||
|
||||
Positions and presses name the panel they were meant for. If focus has moved to
|
||||
another panel since, they go nowhere, so a tap can't land on the wrong one;
|
||||
releases always go, so nothing stays held.
|
||||
"""
|
||||
# Moves may use a focus reading up to a second old; anything that acts (a press, key,
|
||||
# text or scroll) reads it afresh, so it can't land on a panel that took focus since.
|
||||
acts = any(k in event for k in ("button", "key", "text", "scroll")) and event.get("down") is not False
|
||||
if "window" in event:
|
||||
if panel and acts and focus_now() == (panel.get("window"), panel.get("display")):
|
||||
pass # still the same panel (its geometry is re-read on the usual one-second schedule)
|
||||
elif acts or not panel or time.time() - panel.get("_at", 0) > 1 or aimed_elsewhere(event, panel):
|
||||
panel = {**focus(), "_at": time.time()}
|
||||
stale = aimed_elsewhere(event, panel) if "window" in event else False
|
||||
if stale and not (event.get("down") is False and ("button" in event or "key" in event)):
|
||||
say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs
|
||||
STALE[0] = True
|
||||
return panel
|
||||
if STALE[0] and not stale:
|
||||
# Anything that goes through (a trackpad move names no panel) means caught up: stop re-syncing.
|
||||
STALE[0] = False
|
||||
say("ready", focus=(panel or {}).get("window"), display=(panel or {}).get("display"))
|
||||
if "fx" in event and panel and panel.get("window"):
|
||||
gs.move_to(*to_root(panel, number(event["fx"], 1), number(event["fy"], 1)))
|
||||
if "dx" in event or "dy" in event:
|
||||
gs.move_by(number(event.get("dx", 0), 2000), number(event.get("dy", 0), 2000))
|
||||
if event.get("button") in BUTTONS:
|
||||
gs.button(event["button"], event.get("down") is not False)
|
||||
if isinstance(event.get("scroll"), list) and len(event["scroll"]) == 2:
|
||||
gs.scroll(number(event["scroll"][0], 5000), number(event["scroll"][1], 5000))
|
||||
if isinstance(event.get("key"), int) and not isinstance(event["key"], bool) and 0 < event["key"] < 768:
|
||||
gs.key(event["key"], event.get("down") is not False)
|
||||
if isinstance(event.get("text"), str):
|
||||
gs.text(event["text"][:500])
|
||||
return panel
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] == ["focus"]:
|
||||
print(json.dumps(focus()))
|
||||
return 0
|
||||
if sys.argv[1:] == ["panels"]:
|
||||
print(json.dumps(panels()))
|
||||
return 0
|
||||
try:
|
||||
gs = Gamescope()
|
||||
gs.wait_ready()
|
||||
except (OSError, RuntimeError) as e:
|
||||
say("error", message=str(e))
|
||||
return 1
|
||||
say("ready", focus=focus().get("window"))
|
||||
stdin, pending, panel = sys.stdin.fileno(), b"", None
|
||||
try:
|
||||
while True:
|
||||
ready, _, _ = select.select([stdin, gs.fd], [], [], 30)
|
||||
if gs.fd in ready and not gs.pump():
|
||||
say("error", message="gamescope closed its input socket")
|
||||
return 1
|
||||
if stdin not in ready:
|
||||
continue
|
||||
chunk = os.read(stdin, 65536)
|
||||
if not chunk:
|
||||
return 0 # the server went away
|
||||
*lines, pending = (pending + chunk).split(b"\n")
|
||||
for line in lines:
|
||||
waited = False
|
||||
for event in events(line):
|
||||
if gs.device is None and waited:
|
||||
continue # still paused: don't wait again for each event of this batch
|
||||
if gs.device is None:
|
||||
waited = True
|
||||
# Paused (gamescope can pause the device): wait a moment; drop this
|
||||
# event if it doesn't come back. Only a disconnect ends the session.
|
||||
try:
|
||||
gs.wait_ready(2)
|
||||
except RuntimeError:
|
||||
if not gs.alive:
|
||||
raise
|
||||
continue
|
||||
try:
|
||||
panel = apply(gs, event, panel)
|
||||
except (ValueError, KeyError, TypeError, OSError):
|
||||
continue # the server checks events; skip anything odd
|
||||
except RuntimeError as e:
|
||||
say("error", message=str(e))
|
||||
return 1
|
||||
finally:
|
||||
if gs.device is not None:
|
||||
gs.release_all() # never leave a button held down in the headset
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Optional software, separate from Frame Control's own controls and HUD.
|
||||
|
||||
Public reports are attributed leads, never local verification. Compatibility
|
||||
reports reuse the existing database with steam:<appid> package keys.
|
||||
"""
|
||||
REPORT = 'https://www.roadtovr.com/valve-steam-frame-review/'
|
||||
UTILITIES = (
|
||||
(1009850, 'OVR Advanced Settings', False, 'No verified Frame result. Steam edition is paid; the developer also publishes free source/releases.', 'https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings'),
|
||||
(1173510, 'XSOverlay', False, 'Supplied research reports Proton support, but the linked review did not corroborate it on recheck. Untested.', REPORT),
|
||||
(1068820, 'OVR Toolkit', False, 'Supplied research reports Proton support, but the linked review did not corroborate it on recheck. Untested.', REPORT),
|
||||
(908520, 'fpsVR', False, 'No Frame-specific result established in the supplied public research. Untested here.', 'https://store.steampowered.com/app/908520/'),
|
||||
(1494460, 'Desktop+', True, 'Free, developer-published software. No verified Frame result.', 'https://github.com/elvissteinjr/DesktopPlus'),
|
||||
)
|
||||
|
||||
|
||||
def catalogue(ownership, reports):
|
||||
owned = {r['id']: r for r in ownership}
|
||||
out = []
|
||||
for appid, name, free, note, source in UTILITIES:
|
||||
local = owned.get(appid, {})
|
||||
matches = [r for r in reports if r.get('package') == f'steam:{appid}' and r.get('rating') in ('works', 'issues', 'broken')]
|
||||
latest = max(matches, key=lambda r: r.get('date') or '') if matches else None
|
||||
out.append({'id': appid, 'name': name, 'free': free, 'owned': local.get('owned'),
|
||||
'installed': local.get('installed', False), 'frame': local.get('frame', 0),
|
||||
'status': latest['rating'] if latest else 'untested',
|
||||
'report': {k: latest.get(k) for k in ('notes', 'date', 'steamos', 'source')} if latest else None,
|
||||
'note': note, 'source': source,
|
||||
'canInstall': bool(free or local.get('owned'))})
|
||||
return {'utilities': out}
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
"""Frame Control's optional performance HUD, using Frame platform tools only.
|
||||
|
||||
Controls remain blocked pending idle-headset verification; no playspace writes
|
||||
are exposed. See docs/vr-utilities.md for the probe evidence and follow-up.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from frame_status import battery, max_temp, performance
|
||||
|
||||
ROOT = Path.home() / '.local/share/frame-control/vr'
|
||||
APPID = '2000250025'
|
||||
|
||||
|
||||
def validate(body):
|
||||
action = body.get('action')
|
||||
if action not in ('hud-start', 'hud-stop'):
|
||||
raise ValueError('VR action must be hud-start or hud-stop; playspace controls are not yet verified')
|
||||
return action
|
||||
|
||||
|
||||
def save(path, data):
|
||||
tmp = path.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(data))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def process_identity(pid):
|
||||
try:
|
||||
stat = Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()
|
||||
args = Path(f'/proc/{pid}/cmdline').read_bytes().split(b'\0')
|
||||
if b'frame-control-hud' in args and b'xterm' in Path(f'/proc/{pid}/comm').read_bytes():
|
||||
return stat[19] # field 22, starttime; protects against PID reuse
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def panel(action):
|
||||
path = ROOT / 'hud.json'
|
||||
saved = json.loads(path.read_text()) if path.exists() else {}
|
||||
pid = saved.get('pid')
|
||||
running = bool(pid and saved.get('start') and process_identity(pid) == saved['start'])
|
||||
if action == 'hud-stop':
|
||||
if running:
|
||||
os.kill(pid, signal.SIGTERM) # xterm closes the PTY; child exits on HUP
|
||||
path.unlink(missing_ok=True)
|
||||
return {'message': 'HUD closed.'}
|
||||
if running:
|
||||
return {'message': 'HUD is already open. Find Frame Control HUD in the SteamVR dashboard.'}
|
||||
env = {**os.environ, 'DISPLAY': ':0'}
|
||||
p = subprocess.Popen(['xterm', '-name', 'frame-control-hud', '-title', 'Frame Control HUD',
|
||||
'-fa', 'Monospace', '-fs', '20', '-geometry', '56x16',
|
||||
'-bg', '#171d25', '-fg', '#d6d7d8', '-e',
|
||||
sys.executable, str(ROOT / 'frame_vr.py'), 'hud'],
|
||||
env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
try:
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline and p.poll() is None:
|
||||
tree = subprocess.check_output(['xwininfo', '-display', ':0', '-root', '-tree'],
|
||||
text=True, timeout=2)
|
||||
for w in re.findall(r'(0x[0-9a-f]+).*"frame-control-hud"', tree):
|
||||
owner = subprocess.check_output(['xprop', '-display', ':0', '-id', w, '_NET_WM_PID'],
|
||||
text=True, timeout=2)
|
||||
if owner.strip().endswith('= ' + str(p.pid)):
|
||||
subprocess.run(['xprop', '-display', ':0', '-id', w, '-f', 'STEAM_GAME', '32c',
|
||||
'-set', 'STEAM_GAME', APPID], check=True, timeout=2)
|
||||
identity = process_identity(p.pid)
|
||||
if not identity:
|
||||
raise RuntimeError('HUD process exited before its panel was ready')
|
||||
save(path, {'pid': p.pid, 'start': identity})
|
||||
return {'message': 'HUD opened. In SteamVR, select Frame Control HUD and Float in World or dock it to a controller.'}
|
||||
time.sleep(.1)
|
||||
raise RuntimeError('HUD did not create a window; is gamescope running?')
|
||||
except BaseException:
|
||||
if p.poll() is None:
|
||||
p.terminate()
|
||||
p.wait(timeout=3)
|
||||
raise
|
||||
|
||||
|
||||
def hud():
|
||||
def fmt(v, unit=''):
|
||||
return 'unavailable' if v is None else f'{v:.1f}{unit}'
|
||||
while True:
|
||||
p, b = performance(), battery() or {}
|
||||
lines = ['FRAME CONTROL HUD', '',
|
||||
'Compositor FPS ' + fmt(p['compositorFps']),
|
||||
'Compositor period ' + fmt(p['frameMs'], ' ms'),
|
||||
'Application FPS ' + fmt(p['appFps']),
|
||||
'Render GPU time ' + fmt(p['gpuMs'], ' ms'),
|
||||
'Compositor CPU ' + fmt(p['compositorCpuMs'], ' ms'),
|
||||
'System CPU ' + fmt(p['cpuPercent'], '%'),
|
||||
'GPU clock ' + fmt(p['gpuMHz'], ' MHz'),
|
||||
'Hottest sensor ' + fmt(max_temp(), ' C'),
|
||||
'Battery ' + fmt(b.get('percent'), '%'), '',
|
||||
time.strftime('Updated %H:%M:%S'), 'Close this window to stop.']
|
||||
print('\033[2J\033[H' + '\n'.join(lines), flush=True)
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
def dispatch(body):
|
||||
import fcntl # Frame only; validation is also imported by Windows hosts
|
||||
action = validate(body)
|
||||
ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with (ROOT / 'control.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
return panel(action)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[1:] == ['hud']:
|
||||
hud()
|
||||
else:
|
||||
try:
|
||||
print(json.dumps(dispatch(json.load(sys.stdin))))
|
||||
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as e:
|
||||
print(json.dumps({'error': str(e)}))
|
||||
sys.exit(1)
|
||||
+25
-593
@@ -148,18 +148,6 @@
|
||||
.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; }
|
||||
.viewer .badge.live { left: auto; right: 12px; background: var(--bad); color: #fff; font-weight: 700; }
|
||||
.view-actions { display: flex; gap: 10px; }
|
||||
.ctrl-status { margin-top: 10px; font-size: 13px; color: var(--blue); }
|
||||
.ctrl-status::before { content: "● "; }
|
||||
.ctrl-status.warn { color: var(--warn); }
|
||||
/* Control: the view takes taps, clicks, drags and keys, and none of them zoom or pan it. */
|
||||
.viewer.control { outline: 1px solid var(--blue); }
|
||||
.viewer.control canvas { cursor: crosshair; touch-action: none; }
|
||||
.viewer.control.rel canvas { cursor: move; }
|
||||
.viewer.immersive { position: fixed; z-index: 40; inset: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
|
||||
max-height: none; aspect-ratio: auto; border-radius: 0; }
|
||||
.ctrl-keys { margin-top: 10px; }
|
||||
#panelPick { max-width: 240px; height: 30px; }
|
||||
.viewer .stamp { position: absolute; right: 12px; bottom: 12px; font-size: 11px; color: #c7d5e0;
|
||||
background: rgba(0,0,0,.6); padding: 3px 8px; border-radius: 2px; }
|
||||
.viewer .asleep { position: absolute; inset: auto 0 0 0; padding: 14px 18px; text-align: center; color: var(--text);
|
||||
@@ -403,9 +391,6 @@
|
||||
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
|
||||
.toolbar { gap: 8px; }
|
||||
.toolbar .spacer { display: none; }
|
||||
.view-actions { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
|
||||
.view-actions button { justify-content: center; padding: 0 4px; }
|
||||
#panelPick { flex: 1 1 100%; max-width: none; }
|
||||
.shelf-head { flex-wrap: wrap; row-gap: 8px; }
|
||||
.shelf { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; }
|
||||
.cat-grid { grid-template-columns: 1fr; }
|
||||
@@ -485,20 +470,16 @@
|
||||
<div class="toolbar">
|
||||
<div class="seg" aria-label="What to capture">
|
||||
<button data-view="headset" class="on" title="What the headset shows, via the SteamVR compositor">Headset view</button>
|
||||
<button data-view="flat" title="The app panel in use, flat: it stays still however the wearer looks around">Desktop</button>
|
||||
<button data-view="flat" title="gamescope's 2D layer: the desktop panel and Steam's flat UI">Desktop panel</button>
|
||||
</div>
|
||||
<select id="panelPick" class="small" hidden aria-label="Which panel to show"></select>
|
||||
<div class="seg" id="eyeSeg" aria-label="Crop">
|
||||
<button data-eye="left" class="on" title="One eye, like pointing a camera into one lens">Left eye</button>
|
||||
<button data-eye="both" title="Both eyes side by side">Both eyes</button>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="view-actions">
|
||||
<button class="action" id="shotBtn">Capture</button>
|
||||
<button id="liveBtn" title="Keep updating, as video">Live</button>
|
||||
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
|
||||
<button id="saveBtn" disabled>Save</button>
|
||||
</span>
|
||||
<button class="action" id="shotBtn">Capture</button>
|
||||
<button id="liveBtn" title="Keep updating: video of the headset view, or repeated captures of the desktop panel">Live</button>
|
||||
<button id="saveBtn" disabled>Save</button>
|
||||
</div>
|
||||
<div class="viewer" id="viewer">
|
||||
<div class="empty" id="viewerEmpty">
|
||||
@@ -520,25 +501,6 @@
|
||||
<div class="asleep" id="asleep" hidden>The headset is showing a blank frame. It's probably asleep or not being worn. Put it on and capture again.</div>
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="ctrl-status" id="ctrlBadge" role="status" hidden></div>
|
||||
<div class="pad-keys ctrl-keys" id="ctrlKeys" hidden aria-label="Keys and clicks for the Frame">
|
||||
<input id="ctrlType" type="text" placeholder="Type here to type on the Frame" autocomplete="off" autocapitalize="off"
|
||||
autocorrect="off" spellcheck="false" enterkeyhint="send">
|
||||
<span class="seg pad-clicks">
|
||||
<button data-ctrl-click="left" title="Left click where the pointer is">Click</button>
|
||||
<button data-ctrl-click="right" title="Right click where the pointer is">Right-click</button>
|
||||
</span>
|
||||
<span class="seg pad-special">
|
||||
<button data-ctrl-key="1" title="Escape">Esc</button>
|
||||
<button data-ctrl-key="15" title="Tab">Tab</button>
|
||||
<button data-ctrl-key="105" title="Left arrow" aria-label="Left arrow">←</button>
|
||||
<button data-ctrl-key="103" title="Up arrow" aria-label="Up arrow">↑</button>
|
||||
<button data-ctrl-key="108" title="Down arrow" aria-label="Down arrow">↓</button>
|
||||
<button data-ctrl-key="106" title="Right arrow" aria-label="Right arrow">→</button>
|
||||
<button data-ctrl-key="14" title="Backspace" aria-label="Backspace">⌫</button>
|
||||
<button data-ctrl-key="28" title="Enter">Enter</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hint" id="viewHint"></div>
|
||||
</section>
|
||||
|
||||
@@ -576,25 +538,6 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel" id="vrTools">
|
||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||
<div class="stats" id="vrMetrics"></div>
|
||||
<div class="hint">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. Temperature is the hottest readable sensor.</div>
|
||||
<div class="chips">
|
||||
<button class="small" id="hudStart">Open HUD in headset</button>
|
||||
<button class="small" id="hudStop">Close HUD</button>
|
||||
</div>
|
||||
<div class="hint">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.</div>
|
||||
<h3>Playspace and seated comfort</h3>
|
||||
<p class="sub" id="comfortNote">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.</p>
|
||||
<div class="hint">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.</div>
|
||||
<details style="margin-top:16px"><summary>Optional VR software</summary>
|
||||
<p class="sub">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.</p>
|
||||
<button class="small" id="vrUtilitiesRefresh">Check ownership and reports</button>
|
||||
<div class="list" id="vrUtilities"><div class="sub">Load this optional list to check the Frame account.</div></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="comfort" style="margin:20px 0;padding:16px">
|
||||
<div class="shelf-head"><h2>Family and comfort</h2><span class="spacer"></span>
|
||||
<button class="action small" id="castBtn">Cast headset view</button>
|
||||
@@ -861,15 +804,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="panels">
|
||||
<div class="shelf-head"><h2>Panel switcher</h2><span class="spacer"></span>
|
||||
<button class="small" id="panelsRefresh">Refresh</button>
|
||||
<button class="small action" id="panelsOpen">Open in headset</button></div>
|
||||
<div class="list" id="panelList"><div class="sub">Refresh to see open panels.</div></div>
|
||||
<div class="hint">Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
|
||||
Saved spatial layouts are not available yet.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="macview" hidden>
|
||||
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
|
||||
<select id="mvQuality" title="Picture quality and bandwidth">
|
||||
@@ -1040,9 +974,9 @@ const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_
|
||||
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
||||
const HINTS = {
|
||||
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live streams it as video (one eye, about 30 fps); Capture takes a still of both eyes. Captures show everything on screen, including anything private.",
|
||||
flat: "The app panel in use in the headset, flat and still: it doesn't move as the wearer looks around. Pick another panel to watch it. Control lets you tap or click right where you want on it.",
|
||||
flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
|
||||
};
|
||||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", panel: "Desktop", shot: "Screenshot" };
|
||||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
|
||||
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||||
let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
|
||||
|
||||
@@ -1184,7 +1118,6 @@ function refresh() {
|
||||
setConn(true, state.hostname);
|
||||
setOnline(true);
|
||||
} catch (e) {
|
||||
renderPerformance(null);
|
||||
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
|
||||
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
|
||||
} finally {
|
||||
@@ -1195,38 +1128,6 @@ function refresh() {
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
// ---- our VR HUD and optional third-party catalogue ----
|
||||
function renderPerformance(s) {
|
||||
const p = s?.performance || {};
|
||||
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, "%"]];
|
||||
$("vrMetrics").innerHTML = values.map(([label, v, unit]) => `<div class="stat"><div class="k">${label}</div><div class="v">${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}</div></div>`).join("");
|
||||
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
|
||||
}
|
||||
$("hudStart").onclick = () => act("Open HUD", () => api("/api/vr", {action: "hud-start"}), $("hudStart"));
|
||||
$("hudStop").onclick = () => act("Close HUD", () => api("/api/vr", {action: "hud-stop"}), $("hudStop"));
|
||||
async function loadUtilities() {
|
||||
$("vrUtilitiesRefresh").disabled = true;
|
||||
try {
|
||||
const data = await api("/api/vr/utilities");
|
||||
$("vrUtilities").innerHTML = data.utilities.map(u => `<div class="item">
|
||||
<div class="grow"><strong>${esc(u.name)}</strong> · ${esc(u.status)} · ${u.free ? "Free" : "Paid"} · ${u.owned == null ? "Ownership unknown" : u.owned ? "Owned" : "Not in library"}
|
||||
<div class="sub">${esc(u.note)} <a href="${esc(u.source)}" target="_blank" rel="noopener">Source</a></div>
|
||||
${u.report ? `<div class="sub">Report ${esc(u.report.date || "")}: ${esc(u.report.notes || "")} · SteamOS ${esc(u.report.steamos || "unspecified")}</div>` : ""}</div>
|
||||
<button class="small" data-utility="${u.id}" ${u.installed || !u.canInstall ? "disabled" : ""}>${u.installed ? "Installed" : "Install (optional)"}</button></div>`).join("");
|
||||
} catch(e) { failed($("vrUtilities"), e); }
|
||||
finally { $("vrUtilitiesRefresh").disabled = false; }
|
||||
}
|
||||
$("vrUtilitiesRefresh").onclick = loadUtilities;
|
||||
$("vrUtilities").onclick = async e => {
|
||||
const b = e.target.closest("[data-utility]");
|
||||
if (!b) return;
|
||||
await act("Install optional utility", () => api("/api/steam", {action:"install", appid:b.dataset.utility}), b);
|
||||
await loadUtilities();
|
||||
};
|
||||
|
||||
// The Frame owns the clock. Poll independently of status and the selected tab.
|
||||
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
|
||||
async function localNotification(message, request = false) {
|
||||
@@ -1366,7 +1267,6 @@ function battery(b, power) {
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
renderPerformance(s);
|
||||
battery(s.battery, s.power);
|
||||
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
||||
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
||||
@@ -1431,12 +1331,8 @@ function setView(v) {
|
||||
view = v;
|
||||
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
|
||||
$("eyeSeg").style.visibility = v === "headset" && !video.ctl ? "visible" : "hidden";
|
||||
$("eyeSeg").style.display = v === "flat" ? "none" : ""; // the Desktop view has no eyes to pick
|
||||
$("viewHint").textContent = HINTS[v];
|
||||
$("panelPick").hidden = v !== "flat";
|
||||
if (v === "flat") watchPanels(); else clearTimeout(desk.timer);
|
||||
if (live && changed) { toggleLive(false); toggleLive(true); }
|
||||
ctrlShow();
|
||||
if (live && changed) { toggleLive(false); toggleLive(true); } // video for the headset, captures for the panel
|
||||
}
|
||||
function setEye(e) {
|
||||
eye = e;
|
||||
@@ -1463,27 +1359,20 @@ function setZoom(z, cx, cy) {
|
||||
$("zoomIn").onclick = () => setZoom(zoom * 1.5);
|
||||
$("zoomOut").onclick = () => setZoom(zoom / 1.5);
|
||||
$("zoomFit").onclick = () => setZoom(1);
|
||||
// Full screen, or on phones (no element full screen there) the viewer filling the window.
|
||||
$("fullBtn").onclick = () => {
|
||||
if (document.fullscreenElement) return document.exitFullscreen();
|
||||
if ($("viewer").requestFullscreen && !TOUCH) return $("viewer").requestFullscreen().catch(() => $("viewer").classList.toggle("immersive"));
|
||||
$("viewer").classList.toggle("immersive");
|
||||
setZoom(1);
|
||||
};
|
||||
$("fullBtn").onclick = () => document.fullscreenElement ? document.exitFullscreen() : $("viewer").requestFullscreen();
|
||||
document.addEventListener("fullscreenchange", () => setZoom(1));
|
||||
$("viewer").addEventListener("wheel", e => {
|
||||
if ($("canvas").hidden || ctrl.on) return;
|
||||
if ($("canvas").hidden) return;
|
||||
e.preventDefault();
|
||||
const v = $("viewer").getBoundingClientRect();
|
||||
setZoom(zoom * Math.exp(-e.deltaY * 0.002), e.clientX - v.left, e.clientY - v.top);
|
||||
}, { passive: false });
|
||||
$("canvas").addEventListener("dblclick", e => {
|
||||
if (ctrl.on) return;
|
||||
const v = $("viewer").getBoundingClientRect();
|
||||
setZoom(zoom > 1 ? 1 : 2.5, e.clientX - v.left, e.clientY - v.top);
|
||||
});
|
||||
$("canvas").addEventListener("pointerdown", e => {
|
||||
if (zoom === 1 || ctrl.on) return;
|
||||
if (zoom === 1) return;
|
||||
const start = { x: e.clientX - panX, y: e.clientY - panY };
|
||||
$("viewer").classList.add("panning");
|
||||
$("canvas").setPointerCapture(e.pointerId);
|
||||
@@ -1493,10 +1382,7 @@ $("canvas").addEventListener("pointerdown", e => {
|
||||
$("canvas").addEventListener("pointerup", up, { once: true });
|
||||
});
|
||||
document.addEventListener("keydown", e => {
|
||||
if (page !== "home" || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) return;
|
||||
if (ctrl.on && document.activeElement === $("viewer")) return; // keys are going to the Frame
|
||||
if (e.key === "c") return $("ctrlBtn").click();
|
||||
if ($("canvas").hidden) return;
|
||||
if (page !== "home" || $("canvas").hidden || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) return;
|
||||
if (e.key === "+" || e.key === "=") setZoom(zoom * 1.5);
|
||||
else if (e.key === "-") setZoom(zoom / 1.5);
|
||||
else if (e.key === "0") setZoom(1);
|
||||
@@ -1538,14 +1424,8 @@ async function capture() {
|
||||
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
|
||||
let url = null;
|
||||
try {
|
||||
let url_ = "/api/screenshot?view=headset";
|
||||
var shotPanel = null;
|
||||
if (view === "flat") {
|
||||
shotPanel = deskPanel() || (await loadPanels(), deskPanel());
|
||||
if (!shotPanel) throw new Error("no app panel is open in the headset");
|
||||
url_ = `/api/screenshot?view=panel&window=${shotPanel.window}&display=${encodeURIComponent(shotPanel.display)}`;
|
||||
}
|
||||
const r = await fetch(url_, { headers: {"X-Frame-UI": UI_KEY} });
|
||||
const r = await fetch(view === "headset" ? "/api/screenshot?view=headset" : "/api/screenshot",
|
||||
{ headers: {"X-Frame-UI": UI_KEY} });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||
const source = r.headers.get("X-Capture-Source") || "gamescope";
|
||||
url = URL.createObjectURL(await r.blob());
|
||||
@@ -1554,9 +1434,6 @@ async function capture() {
|
||||
if (gen !== viewGen) return true;
|
||||
lastImg = img; lastSource = source; lastShot = null;
|
||||
draw();
|
||||
desk.shown = source === "panel" ? panelKey(shotPanel) : null;
|
||||
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
|
||||
ctrlShow();
|
||||
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
||||
$("saveBtn").disabled = false;
|
||||
return true;
|
||||
@@ -1569,29 +1446,26 @@ async function capture() {
|
||||
$("viewer").classList.remove("busy");
|
||||
}
|
||||
}
|
||||
let liveFailures = 0, liveGen = 0;
|
||||
async function liveLoop(gen = liveGen) {
|
||||
if (!live || gen !== liveGen) return;
|
||||
const ok = await capture();
|
||||
if (gen !== liveGen) return; // Live was restarted (say, as video) while this capture ran
|
||||
liveFailures = ok ? 0 : liveFailures + 1;
|
||||
let liveFailures = 0;
|
||||
async function liveLoop() {
|
||||
if (!live) return;
|
||||
liveFailures = (await capture()) ? 0 : liveFailures + 1;
|
||||
if (liveFailures >= 5) {
|
||||
toggleLive(false);
|
||||
toast(view === "flat" ? "Live stopped: couldn't capture the panel." : "Live stopped after 5 failed captures. Is the headset on?", true);
|
||||
toast("Live stopped after 5 failed captures. Is the headset on?", true);
|
||||
return;
|
||||
}
|
||||
if (live) liveTimer = setTimeout(() => liveLoop(gen), view === "headset" ? 300 : 700);
|
||||
if (live) liveTimer = setTimeout(liveLoop, view === "headset" ? 300 : 700);
|
||||
}
|
||||
function toggleLive(on) {
|
||||
live = on;
|
||||
liveGen++;
|
||||
liveFailures = 0;
|
||||
$("liveBtn").classList.toggle("on", live);
|
||||
$("liveBadge").hidden = !live;
|
||||
clearTimeout(liveTimer);
|
||||
stopVideo();
|
||||
if (!live) return;
|
||||
if ("VideoDecoder" in window && (view === "headset" || deskPanel())) {
|
||||
if (view === "headset" && "VideoDecoder" in window) {
|
||||
const started = startVideo();
|
||||
const ctl = video.ctl;
|
||||
started.catch(e => {
|
||||
@@ -1636,10 +1510,9 @@ async function startVideo() {
|
||||
if (!shown++) {
|
||||
viewGen++; // a capture still in flight mustn't replace the video
|
||||
lastImg = null; lastSource = "video"; lastShot = null;
|
||||
desk.shown = panelKey(panel); ctrlShow();
|
||||
$("viewer").classList.remove("busy");
|
||||
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
||||
$("srcBadge").hidden = false; $("srcBadge").textContent = `${video.label} · video`;
|
||||
$("srcBadge").hidden = false; $("srcBadge").textContent = "Headset view · video";
|
||||
$("stamp").hidden = false; $("saveBtn").disabled = false;
|
||||
}
|
||||
frames++;
|
||||
@@ -1651,11 +1524,7 @@ async function startVideo() {
|
||||
},
|
||||
error: e => log("Video decoder: " + e.message, "e"),
|
||||
});
|
||||
const panel = view === "flat" ? deskPanel() : null;
|
||||
desk.shown = null; // until this stream's first picture
|
||||
video.label = panel ? `Desktop · ${panel.name || "panel"}` : "Headset view";
|
||||
const query = panel ? `src=panel&window=${panel.window}&display=${encodeURIComponent(panel.display)}&${STREAM_QUERY}` : STREAM_QUERY;
|
||||
const r = await fetch(`/api/stream?${query}`, { headers: {"X-Frame-UI": UI_KEY}, signal: ctl.signal });
|
||||
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": UI_KEY}, signal: ctl.signal });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||
const reader = r.body.getReader();
|
||||
let buf = new Uint8Array(0), scan = 0, auStart = -1;
|
||||
@@ -1700,7 +1569,7 @@ function stopVideo() {
|
||||
if (video.dec && video.dec.state !== "closed") video.dec.close();
|
||||
video.ctl = video.dec = null;
|
||||
$("viewer").classList.remove("busy");
|
||||
if (lastSource === "video") { $("srcBadge").textContent = `${video.label || "Headset view"} · video (stopped)`; $("stamp").hidden = true; }
|
||||
if (lastSource === "video") { $("srcBadge").textContent = "Headset view · video (stopped)"; $("stamp").hidden = true; }
|
||||
$("eyeSeg").style.visibility = view === "headset" ? "visible" : "hidden";
|
||||
}
|
||||
$("shotBtn").onclick = () => capture();
|
||||
@@ -1720,421 +1589,6 @@ function download(blob, name) {
|
||||
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view));
|
||||
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
|
||||
|
||||
// ---- Desktop view: the app panel in use in the headset (or one picked to watch) ----
|
||||
// gamescope says which panel has focus: the one the wearer last used. That's the one
|
||||
// Control reaches, so the view follows it unless you pick another to watch.
|
||||
// Panels are "display/window": window ids are per X display, and the Frame has two.
|
||||
// desk.shown is the panel whose picture is on screen now; Control only acts on that one.
|
||||
const desk = { panels: [], focus: null, pick: "", timer: null, loaded: false, shown: null };
|
||||
const panelKey = p => p ? `${p.display}/${p.window}` : null;
|
||||
function deskPanel() {
|
||||
const want = desk.pick || desk.focus;
|
||||
// Nothing in use and nothing picked: show the first panel rather than nothing.
|
||||
return desk.panels.find(p => panelKey(p) === want) || (!desk.pick && !desk.focus && desk.panels[0]) || null;
|
||||
}
|
||||
async function loadPanels() {
|
||||
try {
|
||||
const before = panelKey(deskPanel());
|
||||
const r = await api("/api/panels");
|
||||
desk.panels = r.panels || []; desk.loaded = true;
|
||||
desk.focus = r.focus ? `${r.focus_display}/${r.focus}` : null;
|
||||
if (desk.pick && !desk.panels.some(p => panelKey(p) === desk.pick)) desk.pick = "";
|
||||
renderPanelPick();
|
||||
const now = panelKey(deskPanel());
|
||||
if (view === "flat" && live && now !== before) { toggleLive(false); toggleLive(true); }
|
||||
ctrlShow();
|
||||
} catch (e) {
|
||||
log("Couldn't list the headset's panels: " + e.message, "e");
|
||||
}
|
||||
}
|
||||
function watchPanels() {
|
||||
clearTimeout(desk.timer);
|
||||
if (view !== "flat") return;
|
||||
loadPanels().finally(() => { if (view === "flat") desk.timer = setTimeout(watchPanels, document.hidden ? 10000 : 3000); });
|
||||
}
|
||||
function renderPanelPick() {
|
||||
const sel = $("panelPick");
|
||||
sel.innerHTML = `<option value="">${desk.focus ? "Panel in use" : "Panel in use (none yet)"}</option>` +
|
||||
desk.panels.map(p => `<option value="${esc(panelKey(p))}">${esc(p.name || "Untitled")}${p.focused ? " (in use)" : ""}</option>`).join("");
|
||||
sel.value = desk.pick;
|
||||
}
|
||||
$("panelPick").onchange = () => {
|
||||
desk.pick = $("panelPick").value;
|
||||
ctrlShow();
|
||||
if (live) { toggleLive(false); toggleLive(true); } else if (lastImg || lastSource) capture();
|
||||
};
|
||||
|
||||
// ---- Control: tap or click on the view to use the Frame ----
|
||||
// Through gamescope's own input (frame_touch.py). On the Desktop view a tap lands
|
||||
// exactly where you tapped; on the headset view, which moves with the wearer's head,
|
||||
// the view is a trackpad for the pointer instead.
|
||||
const ctrl = { on: false, queue: [], sending: false, state: "off", message: "", move: null, rel: [0, 0], raf: 0,
|
||||
held: new Set(), keys: new Set(), pointers: new Map(), g: null, retry: null };
|
||||
const EVDEV = { Escape: 1, Minus: 12, Equal: 13, Backspace: 14, Tab: 15, BracketLeft: 26, BracketRight: 27, Enter: 28,
|
||||
ControlLeft: 29, Semicolon: 39, Quote: 40, Backquote: 41, ShiftLeft: 42, Backslash: 43, Comma: 51, Period: 52,
|
||||
Slash: 53, ShiftRight: 54, NumpadMultiply: 55, AltLeft: 56, Space: 57, CapsLock: 58, F11: 87, F12: 88,
|
||||
IntlBackslash: 86, NumpadEnter: 96, ControlRight: 97, NumpadDivide: 98, AltRight: 100, Home: 102, ArrowUp: 103,
|
||||
PageUp: 104, ArrowLeft: 105, ArrowRight: 106, End: 107, ArrowDown: 108, PageDown: 109, Insert: 110, Delete: 111,
|
||||
MetaLeft: 125, MetaRight: 126, ContextMenu: 127, NumpadSubtract: 74, NumpadAdd: 78, NumpadDecimal: 83 };
|
||||
[..."qwertyuiop"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 16 + i);
|
||||
[..."asdfghjkl"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 30 + i);
|
||||
[..."zxcvbnm"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 44 + i);
|
||||
[..."1234567890"].forEach((c, i) => EVDEV["Digit" + c] = 2 + i);
|
||||
for (let i = 1; i <= 10; i++) EVDEV["F" + i] = 58 + i;
|
||||
[[7, 71], [8, 72], [9, 73], [4, 75], [5, 76], [6, 77], [1, 79], [2, 80], [3, 81], [0, 82]].forEach(([n, k]) => EVDEV["Numpad" + n] = k);
|
||||
const CTRL_BUTTONS = ["left", "middle", "right"]; // MouseEvent.button 0, 1, 2
|
||||
|
||||
function ctrlMode() { return view === "flat" ? "abs" : "rel"; }
|
||||
// Where taps go on the Desktop view, or why they can't.
|
||||
function ctrlTarget() {
|
||||
if (ctrlMode() === "rel") return { ok: true };
|
||||
const p = deskPanel();
|
||||
if (!desk.loaded) return { why: "Finding the headset's panels…" };
|
||||
if (!p) return { why: "No app panel is open in the headset." };
|
||||
if (!desk.focus) return { why: "Nobody's using a panel in the headset yet. Taps work once one is in use." };
|
||||
if (panelKey(p) !== desk.focus) return { why: "Only the panel in use takes taps. Pick “Panel in use” to control it." };
|
||||
if (desk.shown !== desk.focus) return { why: live ? "Waiting for the picture of the panel in use…"
|
||||
: "The panel in use has changed. Press Capture or Live to see it first." };
|
||||
return { ok: true, panel: p };
|
||||
}
|
||||
function ctrlShow() {
|
||||
const v = $("viewer"), b = $("ctrlBadge");
|
||||
$("ctrlBtn").classList.toggle("on", ctrl.on);
|
||||
v.classList.toggle("control", ctrl.on);
|
||||
v.classList.toggle("rel", ctrl.on && ctrlMode() === "rel");
|
||||
$("ctrlKeys").hidden = !ctrl.on;
|
||||
b.hidden = !ctrl.on;
|
||||
if (!ctrl.on) return;
|
||||
const t = ctrlTarget();
|
||||
let text = ctrlMode() === "abs" ? `Tap or click to use ${t.panel ? t.panel.name || "the panel" : "the panel"}`
|
||||
: "Drag to move the pointer, tap to click, two fingers to scroll";
|
||||
let warn = false;
|
||||
if (!t.ok) { text = t.why; warn = true; }
|
||||
else if (ctrl.state === "error") { text = ctrl.message || "Control isn't working"; warn = true; }
|
||||
else if (ctrl.state === "starting") text = "Connecting…";
|
||||
b.textContent = text;
|
||||
b.classList.toggle("warn", warn);
|
||||
}
|
||||
function ctrlToggle(on) {
|
||||
if (!on) ctrlRelease();
|
||||
ctrl.on = on;
|
||||
if (on) {
|
||||
setZoom(1);
|
||||
ctrlPoll(0);
|
||||
if (view === "flat" && !desk.loaded) watchPanels();
|
||||
if (!TOUCH) $("viewer").focus({ preventScroll: true });
|
||||
}
|
||||
if (window.frameApp && window.frameApp.captureKeys) window.frameApp.captureKeys(on && document.activeElement === $("viewer"));
|
||||
ctrlShow();
|
||||
}
|
||||
$("ctrlBtn").onclick = () => ctrlToggle(!ctrl.on);
|
||||
// Until it's ready, check on the Frame side every half second (for up to 15 s).
|
||||
async function ctrlPoll(n) {
|
||||
if (!ctrl.on) return;
|
||||
try {
|
||||
const r = await api(n ? "/api/touch" : "/api/touch?start=1");
|
||||
ctrl.state = r.state; ctrl.message = r.message || "";
|
||||
} catch (e) { ctrl.state = "error"; ctrl.message = e.message; }
|
||||
if (!ctrl.on) return; // turned off while connecting
|
||||
ctrlShow();
|
||||
if (ctrl.state !== "ready" && ctrl.state !== "error" && n < 30) return setTimeout(() => ctrlPoll(n + 1), 500);
|
||||
if (ctrl.state === "ready") return ctrlFlush();
|
||||
if (ctrl.state !== "error") {
|
||||
ctrl.state = "error"; ctrl.message = "The Frame didn't answer. Turn Control off and on to try again.";
|
||||
ctrlShow();
|
||||
}
|
||||
}
|
||||
$("viewer").tabIndex = 0;
|
||||
$("viewer").addEventListener("focus", () => { if (ctrl.on && window.frameApp?.captureKeys) window.frameApp.captureKeys(true); });
|
||||
$("viewer").addEventListener("blur", () => { ctrlRelease(); if (window.frameApp?.captureKeys) window.frameApp.captureKeys(false); });
|
||||
document.addEventListener("visibilitychange", () => { if (document.hidden) ctrlRelease(); });
|
||||
window.addEventListener("blur", () => ctrlRelease()); // another app took the keyboard
|
||||
|
||||
const isMoveEvent = e => ("fx" in e || "dx" in e) && !("button" in e) && !("key" in e);
|
||||
const isRelease = e => ("button" in e || "key" in e) && e.down === false;
|
||||
// While it connects, keep clicks and keys, each with the last position before it, so a
|
||||
// tap still lands where it was made; lone moves are stale by then.
|
||||
function ctrlKeepable(events) {
|
||||
const out = [];
|
||||
let move = null;
|
||||
for (const e of events) {
|
||||
if (isMoveEvent(e)) { if ("fx" in e) move = e; continue; }
|
||||
if (move && "button" in e && e.down !== false) out.push(move);
|
||||
move = null;
|
||||
out.push(e);
|
||||
}
|
||||
if (move) out.push(move); // the last position: a click queued after it needs it
|
||||
return out;
|
||||
}
|
||||
function ctrlSend(events) {
|
||||
ctrl.queue.push(...events.map(e => isMoveEvent(e) ? e : ctrlAim(e)).filter(Boolean));
|
||||
// Keep order; if the Frame is slow to answer, drop pointer moves first, then the oldest
|
||||
// presses and scrolls, never a release.
|
||||
if (ctrl.queue.length > 300) {
|
||||
const q = ctrlKeepable(ctrl.queue), keep = new Set();
|
||||
q.forEach((e, i) => { if (isRelease(e) || i >= q.length - 200) keep.add(i); });
|
||||
// A kept press keeps the position before it, so it lands where it was made.
|
||||
for (const i of [...keep]) if (i > 0 && "button" in q[i] && q[i].down !== false && "fx" in q[i - 1]) keep.add(i - 1);
|
||||
ctrl.queue = q.filter((e, i) => keep.has(i));
|
||||
}
|
||||
ctrlFlush();
|
||||
}
|
||||
async function ctrlFlush() {
|
||||
if (ctrl.sending || !ctrl.queue.length) return;
|
||||
// Backing off after a failure: new input waits for the same retry.
|
||||
const wait = (ctrl.retryAt || 0) - Date.now();
|
||||
if (wait > 0) {
|
||||
clearTimeout(ctrl.retry);
|
||||
ctrl.retry = setTimeout(ctrlFlush, wait);
|
||||
return;
|
||||
}
|
||||
ctrl.sending = true;
|
||||
// Up to 200 events, and at most about 100 characters of text: the Frame types a batch
|
||||
// key by key before reading the next, and a release mustn't wait behind a long paste.
|
||||
let n = 0, chars = 0;
|
||||
while (n < ctrl.queue.length && n < 200 && (n === 0 || chars + (ctrl.queue[n].text?.length || 0) <= 100)) {
|
||||
chars += ctrl.queue[n].text?.length || 0;
|
||||
n++;
|
||||
}
|
||||
const batch = ctrl.queue.splice(0, n);
|
||||
let again = 0;
|
||||
try {
|
||||
const r = await api("/api/touch", { events: batch });
|
||||
ctrl.state = r.state; ctrl.message = r.message || "";
|
||||
if (r.stale && typeof loadPanels === "function") loadPanels(); // focus moved on: catch up now
|
||||
if (!r.sent && r.state === "error") {
|
||||
// Broken: presses would be stale by the time it's back; releases still matter.
|
||||
ctrl.queue.unshift(...batch.filter(isRelease));
|
||||
again = 2000;
|
||||
} else if (!r.sent) {
|
||||
ctrl.queue.unshift(...ctrlKeepable(batch)); // connecting: send them once it's ready
|
||||
again = 300;
|
||||
}
|
||||
} catch (e) {
|
||||
ctrl.state = "error"; ctrl.message = e.message;
|
||||
ctrl.queue.unshift(...batch.filter(isRelease));
|
||||
again = 1000; // try the releases again in a moment, not in a tight loop
|
||||
} finally {
|
||||
ctrl.sending = false;
|
||||
ctrlShow();
|
||||
clearTimeout(ctrl.retry);
|
||||
ctrl.retryAt = again ? Date.now() + again : 0;
|
||||
if (again) { if (ctrl.queue.length) ctrl.retry = setTimeout(ctrlFlush, again); }
|
||||
else if (ctrl.queue.length) ctrlFlush();
|
||||
}
|
||||
}
|
||||
// Pointer moves: at most one per animation frame.
|
||||
function ctrlMoveTo(fx, fy) {
|
||||
const p = ctrlTarget().panel;
|
||||
if (!p) return;
|
||||
ctrl.move = { fx, fy, window: p.window, display: p.display };
|
||||
ctrlSchedule();
|
||||
}
|
||||
function ctrlMoveBy(dx, dy) { ctrl.rel[0] += dx; ctrl.rel[1] += dy; ctrlSchedule(); }
|
||||
function ctrlSchedule() { if (!ctrl.raf) ctrl.raf = requestAnimationFrame(ctrlFlushMoves); }
|
||||
function ctrlFlushMoves() {
|
||||
ctrl.raf = 0;
|
||||
const out = [];
|
||||
if (ctrl.move) { out.push(ctrl.move); ctrl.move = null; }
|
||||
if (ctrl.rel[0] || ctrl.rel[1]) { out.push({ dx: Math.round(ctrl.rel[0]), dy: Math.round(ctrl.rel[1]) }); ctrl.rel = [0, 0]; }
|
||||
if (out.length) ctrlSend(out);
|
||||
}
|
||||
// On the Desktop view, presses name the panel they're for, so the Frame drops them if
|
||||
// focus has moved on (releases always go).
|
||||
// A press with no panel to aim at (the Desktop view lost its target) isn't sent at all.
|
||||
function ctrlAim(e) {
|
||||
if (ctrlMode() !== "abs" || e.down === false) return e;
|
||||
const p = ctrlTarget().panel;
|
||||
return p ? { ...e, window: p.window, display: p.display } : null;
|
||||
}
|
||||
function ctrlButton(name, down) {
|
||||
ctrlFlushMoves(); // the click lands where the pointer was sent
|
||||
if (down) ctrl.held.add(name); else ctrl.held.delete(name);
|
||||
ctrlSend([ctrlAim({ button: name, down })]);
|
||||
}
|
||||
function ctrlKeyEvent(code, down) {
|
||||
ctrlFlushMoves();
|
||||
if (down) ctrl.keys.add(code); else ctrl.keys.delete(code);
|
||||
ctrlSend([ctrlAim({ key: code, down })]);
|
||||
}
|
||||
function ctrlClick(name = "left") { ctrlButton(name, true); ctrlButton(name, false); }
|
||||
function ctrlRelease() {
|
||||
for (const b of [...ctrl.held]) ctrlButton(b, false);
|
||||
for (const k of [...ctrl.keys]) ctrlKeyEvent(k, false);
|
||||
if (ctrl.g && ctrl.g.timer) clearTimeout(ctrl.g.timer);
|
||||
ctrl.pointers.clear(); ctrl.g = null;
|
||||
}
|
||||
// Where a pointer event falls on the panel, as fractions (the canvas box includes any letterboxing).
|
||||
function ctrlFraction(e) {
|
||||
const c = $("canvas"), r = c.getBoundingClientRect();
|
||||
const scale = Math.min(r.width / c.width, r.height / c.height);
|
||||
const w = c.width * scale, h = c.height * scale;
|
||||
const x = (e.clientX - r.left - (r.width - w) / 2) / w, y = (e.clientY - r.top - (r.height - h) / 2) / h;
|
||||
return x >= 0 && x <= 1 && y >= 0 && y <= 1 ? [x, y] : null;
|
||||
}
|
||||
|
||||
// Mouse (and pen) on a computer: the view is the Frame's screen.
|
||||
function ctrlMouse(e) {
|
||||
if (ctrlMode() === "abs") {
|
||||
const f = ctrlFraction(e);
|
||||
if (f) ctrlMoveTo(...f);
|
||||
} else if (e.type === "pointermove" && (e.movementX || e.movementY)) {
|
||||
ctrlMoveBy(e.movementX * 1.5, e.movementY * 1.5);
|
||||
}
|
||||
}
|
||||
// Touch: tap to click, press and hold to right-click, two fingers to scroll. Dragging is a
|
||||
// mouse drag on the Desktop view (sliders, selecting text) and moves the pointer on the headset view.
|
||||
const TAP_MOVE = 8, HOLD_MS = 550, TOUCH_SPEED = 1.6, SCROLL_SPEED = 2;
|
||||
function ctrlTouchDown(e) {
|
||||
ctrl.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (ctrl.pointers.size === 1) {
|
||||
const g = ctrl.g = { kind: "maybe", x: e.clientX, y: e.clientY, e, target: ctrlAimedAt() };
|
||||
g.timer = setTimeout(() => {
|
||||
if (ctrl.g !== g || g.kind !== "maybe" || !ctrlSameTarget(g)) return;
|
||||
g.kind = "held";
|
||||
if (ctrlMode() === "abs") {
|
||||
const f = ctrlFraction(g.e);
|
||||
if (!f) return; // on the bars around the picture: nothing to press
|
||||
ctrlMoveTo(...f);
|
||||
}
|
||||
ctrlClick("right");
|
||||
navigator.vibrate?.(10);
|
||||
}, HOLD_MS);
|
||||
} else if (ctrl.pointers.size === 2) {
|
||||
if (ctrl.g?.timer) clearTimeout(ctrl.g.timer);
|
||||
if (ctrl.held.has("left")) ctrlButton("left", false);
|
||||
ctrl.g = { kind: "scroll", ...centroid() };
|
||||
}
|
||||
}
|
||||
// The panel a gesture started on; if that stops being the one in use, the gesture
|
||||
// does nothing more (except let go of what it holds).
|
||||
function ctrlAimedAt() { return ctrlMode() === "abs" ? panelKey(ctrlTarget().panel) : "headset"; }
|
||||
function ctrlSameTarget(g) { return g.target && g.target === ctrlAimedAt(); }
|
||||
function centroid() {
|
||||
const ps = [...ctrl.pointers.values()];
|
||||
return { x: ps.reduce((a, p) => a + p.x, 0) / ps.length, y: ps.reduce((a, p) => a + p.y, 0) / ps.length };
|
||||
}
|
||||
function ctrlTouchMove(e) {
|
||||
const prev = ctrl.pointers.get(e.pointerId);
|
||||
if (!prev || !ctrl.g) return;
|
||||
const g = ctrl.g, dx = e.clientX - prev.x, dy = e.clientY - prev.y;
|
||||
ctrl.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (g.kind === "scroll") {
|
||||
const c = centroid();
|
||||
// Content follows the fingers, as on the phone itself.
|
||||
ctrlSend([{ scroll: [-(c.x - g.x) * SCROLL_SPEED, -(c.y - g.y) * SCROLL_SPEED] }]);
|
||||
g.x = c.x; g.y = c.y;
|
||||
return;
|
||||
}
|
||||
if (g.kind === "maybe" && Math.hypot(e.clientX - g.x, e.clientY - g.y) > TAP_MOVE) {
|
||||
clearTimeout(g.timer);
|
||||
if (!ctrlSameTarget(g)) { g.kind = "done"; return; }
|
||||
if (ctrlMode() === "abs") {
|
||||
const f = ctrlFraction(g.e);
|
||||
if (f) { ctrlMoveTo(...f); ctrlButton("left", true); }
|
||||
g.kind = "drag";
|
||||
} else g.kind = "move";
|
||||
}
|
||||
if (g.kind === "drag") { const f = ctrlFraction(e); if (f) ctrlMoveTo(...f); }
|
||||
else if (g.kind === "move") ctrlMoveBy(dx * TOUCH_SPEED, dy * TOUCH_SPEED);
|
||||
}
|
||||
function ctrlTouchUp(e) {
|
||||
const g = ctrl.g;
|
||||
ctrl.pointers.delete(e.pointerId);
|
||||
if (!g) return;
|
||||
if (g.kind === "scroll") g.kind = "done"; // the finger left behind doesn't jump the scroll or drag
|
||||
if (g.kind === "maybe" && !ctrlSameTarget(g)) g.kind = "done";
|
||||
if (g.kind === "maybe" && ctrl.pointers.size === 0) {
|
||||
clearTimeout(g.timer);
|
||||
if (ctrlMode() === "abs") { const f = ctrlFraction(g.e); if (!f) { ctrl.g = null; return; } ctrlMoveTo(...f); }
|
||||
ctrlClick("left");
|
||||
} else if (g.kind === "drag") ctrlButton("left", false);
|
||||
if (ctrl.pointers.size === 0) ctrl.g = null;
|
||||
}
|
||||
// The system took the touch (a swipe home, a call): no tap, and let go of anything held.
|
||||
function ctrlTouchCancel(e) {
|
||||
ctrl.pointers.delete(e.pointerId);
|
||||
if (ctrl.g?.timer) clearTimeout(ctrl.g.timer);
|
||||
if (ctrl.held.has("left")) ctrlButton("left", false);
|
||||
if (ctrl.g) ctrl.g.kind = "done";
|
||||
if (ctrl.pointers.size === 0) ctrl.g = null;
|
||||
}
|
||||
const canvasEl = $("canvas");
|
||||
canvasEl.addEventListener("pointerdown", e => {
|
||||
if (!ctrl.on) return;
|
||||
e.preventDefault();
|
||||
$("viewer").focus({ preventScroll: true });
|
||||
try { canvasEl.setPointerCapture(e.pointerId); } catch { /* a pointer that's already gone */ }
|
||||
if (!ctrlTarget().ok) return ctrlShow();
|
||||
if (e.pointerType === "touch") return ctrlTouchDown(e);
|
||||
if (ctrlMode() === "abs" && !ctrlFraction(e)) return; // on the bars around the picture
|
||||
ctrlMouse(e);
|
||||
ctrlButton(CTRL_BUTTONS[e.button] || "left", true);
|
||||
});
|
||||
canvasEl.addEventListener("pointermove", e => {
|
||||
if (!ctrl.on || !ctrlTarget().ok) return;
|
||||
if (e.pointerType === "touch") return ctrlTouchMove(e);
|
||||
ctrlMouse(e);
|
||||
});
|
||||
for (const type of ["pointerup", "pointercancel"]) canvasEl.addEventListener(type, e => {
|
||||
if (!ctrl.on) return;
|
||||
if (e.pointerType === "touch") return type === "pointercancel" ? ctrlTouchCancel(e) : ctrlTouchUp(e);
|
||||
if (type === "pointercancel") return [...ctrl.held].forEach(b => ctrlButton(b, false)); // no button is named
|
||||
const name = CTRL_BUTTONS[e.button] || "left";
|
||||
if (ctrl.held.has(name)) ctrlButton(name, false);
|
||||
});
|
||||
canvasEl.addEventListener("contextmenu", e => { if (ctrl.on) e.preventDefault(); });
|
||||
$("viewer").addEventListener("wheel", e => {
|
||||
if (!ctrl.on || !ctrlTarget().ok) return;
|
||||
e.preventDefault();
|
||||
const unit = e.deltaMode === 1 ? 40 : e.deltaMode === 2 ? 800 : 1;
|
||||
ctrlSend([{ scroll: [e.deltaX * unit, e.deltaY * unit] }]);
|
||||
}, { passive: false });
|
||||
// Keys while the view has focus go to the Frame (⌘ as Ctrl on a Mac); click elsewhere to stop.
|
||||
function ctrlKey(e, down) {
|
||||
if (!ctrl.on || document.activeElement !== $("viewer") || (down && !ctrlTarget().ok)) return;
|
||||
let code = EVDEV[e.code];
|
||||
if (IS_MAC && (e.code === "MetaLeft" || e.code === "MetaRight")) code = 29;
|
||||
if (!code) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // it's for the Frame, not Frame Control's own shortcuts
|
||||
if (down && e.repeat && [29, 42, 54, 56, 97, 100, 125, 126].includes(code)) return;
|
||||
if (!down && !ctrl.keys.has(code)) return;
|
||||
ctrlKeyEvent(code, down);
|
||||
}
|
||||
$("viewer").addEventListener("keydown", e => ctrlKey(e, true));
|
||||
$("viewer").addEventListener("keyup", e => ctrlKey(e, false));
|
||||
|
||||
// Phone keyboard: the field types plain text; the buttons send single keys and clicks.
|
||||
function ctrlTap(code) { ctrlKeyEvent(code, true); ctrlKeyEvent(code, false); }
|
||||
let ctrlWarned = false;
|
||||
function ctrlText(text) {
|
||||
const plain = text.replace(/[^\x20-\x7e\n\t]/g, "");
|
||||
if (plain !== text && !ctrlWarned) {
|
||||
ctrlWarned = true;
|
||||
toast("Control types plain letters, numbers and symbols. For accents and emoji, use Keyboard and trackpad below.");
|
||||
}
|
||||
// Small pieces: the Frame types them key by key, and a release queued behind mustn't wait long.
|
||||
for (const part of plain.match(/[\s\S]{1,100}/g) || []) ctrlSend([{ text: part }]);
|
||||
}
|
||||
$("ctrlType").addEventListener("beforeinput", e => {
|
||||
if (e.isComposing) return;
|
||||
e.preventDefault();
|
||||
if (!ctrlTarget().ok) return ctrlShow();
|
||||
if (e.inputType === "insertLineBreak" || e.inputType === "insertParagraph") return ctrlTap(28);
|
||||
if (e.inputType === "deleteContentBackward") return ctrlTap(14);
|
||||
if (e.inputType === "deleteContentForward") return ctrlTap(111);
|
||||
const text = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
|
||||
if (text) ctrlText(text);
|
||||
});
|
||||
$("ctrlType").addEventListener("compositionend", e => { if (e.data) ctrlText(e.data); $("ctrlType").value = ""; });
|
||||
$("ctrlKeys").addEventListener("click", e => {
|
||||
const k = e.target.closest("[data-ctrl-key]"), c = e.target.closest("[data-ctrl-click]");
|
||||
if (!ctrlTarget().ok) return ctrlShow();
|
||||
if (k) ctrlTap(+k.dataset.ctrlKey);
|
||||
if (c) ctrlClick(c.dataset.ctrlClick);
|
||||
});
|
||||
|
||||
// ---- controls ----
|
||||
$("refreshAll").onclick = () => { refresh(); loadTitles(); }; // titles too: the Frame may have been asleep at start
|
||||
document.addEventListener("keydown", e => {
|
||||
@@ -3063,7 +2517,6 @@ async function openShot(s) {
|
||||
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
|
||||
if (gen !== viewGen) return;
|
||||
lastImg = img; lastSource = "shot"; lastShot = { blob, file: s.file };
|
||||
desk.shown = null; // a screenshot, not a panel: Control has nothing to act on here
|
||||
draw();
|
||||
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
|
||||
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
|
||||
@@ -3096,27 +2549,6 @@ $("shotsRefresh").onclick = loadShots;
|
||||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
||||
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||||
|
||||
// ---- Panel switcher: our UI over SteamVR's panel API ----
|
||||
let panelSeq = 0;
|
||||
async function loadPanels() {
|
||||
const seq = ++panelSeq;
|
||||
try {
|
||||
const s = await api("/api/panels", {action: "list"});
|
||||
if (seq !== panelSeq) return;
|
||||
$("panelList").innerHTML = s.panels.length ? s.panels.map(p =>
|
||||
`<div class="item"><div class="grow"><div class="t">${esc(p.title)}</div></div>
|
||||
<button class="small" data-panel="${esc(p.key)}">Show</button></div>`).join("")
|
||||
: '<div class="sub">No open panels.</div>';
|
||||
} catch (e) { if (seq === panelSeq) failed($("panelList"), e); }
|
||||
}
|
||||
$("panelsRefresh").onclick = loadPanels;
|
||||
$("panelsOpen").onclick = e => act("Open panel switcher in headset",
|
||||
() => api("/api/panels", {action: "open"}), e.currentTarget);
|
||||
$("panelList").onclick = e => {
|
||||
const b = e.target.closest("[data-panel]"); if (!b) return;
|
||||
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
|
||||
};
|
||||
|
||||
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
|
||||
let mvSeq = 0, mvTimer = 0;
|
||||
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
|
||||
@@ -3195,7 +2627,7 @@ $("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" };
|
||||
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools" };
|
||||
let page = "home";
|
||||
function showPage() {
|
||||
const id = location.hash.slice(1);
|
||||
@@ -3207,7 +2639,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 window.scrollTo(0, 0);
|
||||
if (page === "tools") { loadMacView(); loadPanels(); }
|
||||
if (page === "tools") loadMacView();
|
||||
}
|
||||
window.addEventListener("hashchange", showPage);
|
||||
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
|
||||
|
||||
+7
-188
@@ -48,15 +48,11 @@ import frame_comfort # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
import frame_media # noqa: E402
|
||||
import frame_panels # noqa: E402
|
||||
import frame_report # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
import frame_telemetry # noqa: E402
|
||||
import frame_titles # noqa: E402
|
||||
import frame_webinstall # noqa: E402
|
||||
import frame_vr # noqa: E402
|
||||
import frame_utilities # noqa: E402
|
||||
import frame_compat_db # noqa: E402
|
||||
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
@@ -436,7 +432,6 @@ _stream_proc = None
|
||||
|
||||
|
||||
def stream_command(query):
|
||||
"""The ffmpeg that streams H.264: the headset view, or one panel's own window (src=panel)."""
|
||||
q = parse_qs(query)
|
||||
try:
|
||||
height = int((q.get("h") or ["720"])[0])
|
||||
@@ -446,16 +441,6 @@ def stream_command(query):
|
||||
if height not in STREAM_HEIGHTS or fps not in STREAM_FPS:
|
||||
raise Failure(f"h must be one of {STREAM_HEIGHTS} and fps one of {STREAM_FPS}", 400)
|
||||
rate = 3 if height == 720 else 6 # Mbit/s
|
||||
if q.get("src") == ["panel"]:
|
||||
# The panel's own pixels (x11grab of its window: gamescope keeps them), fitted
|
||||
# to the height asked for. It stays still however the wearer moves their head.
|
||||
window, display = panel_target(q)
|
||||
return (f"DISPLAY={display} ffmpeg -hide_banner -loglevel error -nostdin -f x11grab -framerate {fps} "
|
||||
f"-window_id {window} -i {display} "
|
||||
f"-vf \"scale=-2:'trunc(min({height},ih)/2)*2',format=yuv420p\" -c:v libx264 -preset ultrafast "
|
||||
f"-tune zerolatency -g {fps * 2} -bf 0 -b:v {rate}M -maxrate {rate}M -bufsize {rate // 2 or 1}M "
|
||||
f"-x264-params aud=1:repeat-headers=1 -f h264 - & p=$!; "
|
||||
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
|
||||
return (f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
|
||||
f"ffmpeg -hide_banner -loglevel error -nostdin -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE} "
|
||||
f"-vf fps={fps},scale=-2:{height},format=yuv420p -c:v libx264 -preset ultrafast -tune zerolatency "
|
||||
@@ -508,27 +493,6 @@ def steam(body):
|
||||
return res
|
||||
|
||||
|
||||
def vr(body):
|
||||
try:
|
||||
action = frame_vr.validate(body)
|
||||
except ValueError as e:
|
||||
raise Failure(str(e), 400)
|
||||
sources = {name: (HERE / (name + '.py')).read_text() for name in ('frame_status', 'frame_vr')}
|
||||
script = "import sys, types, json, os\n"
|
||||
for name, source in sources.items():
|
||||
script += f"m = types.ModuleType({name!r}); sys.modules[{name!r}] = m\nexec({source!r}, m.__dict__)\n"
|
||||
# Only the optional HUD needs files: its terminal child survives this SSH call.
|
||||
if action == 'hud-start':
|
||||
script += "m.ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)\n"
|
||||
for name, source in sources.items():
|
||||
script += f"p = m.ROOT / {name + '.py'!r}; t = p.with_suffix('.' + str(os.getpid()) + '.tmp')\nt.write_text({source!r}); os.replace(t, p)\n"
|
||||
script += f"try:\n print(json.dumps(m.dispatch({body!r})))\nexcept Exception as e:\n print(json.dumps({{'error': str(e)}}))\n"
|
||||
result = json.loads(ssh('python3 -', stdin=script, timeout=20))
|
||||
if result.get('error'):
|
||||
raise Failure(result['error'])
|
||||
return result
|
||||
|
||||
|
||||
def steam_search(query):
|
||||
q = parse_qs(query)
|
||||
cc = (q.get("cc") or [""])[0].upper()
|
||||
@@ -696,7 +660,7 @@ class InputAgent:
|
||||
"""
|
||||
|
||||
def __init__(self, source=HERE / "frame_input_agent.py", packages=None):
|
||||
self.source, self.proc, self.lock, self.write_lock = source, None, threading.Lock(), threading.Lock()
|
||||
self.source, self.proc, self.lock = source, None, threading.Lock()
|
||||
self.packages = kdeconnect_packages() if packages is None else packages
|
||||
# generation counts stop()s; launching is the generation a launch is under way for.
|
||||
self.status, self.launching, self.generation = {"state": "off"}, None, 0
|
||||
@@ -869,10 +833,8 @@ class InputAgent:
|
||||
self.start()
|
||||
elif ready and events:
|
||||
try:
|
||||
# One writer at a time: two devices sending at once mustn't tear a line.
|
||||
with self.write_lock:
|
||||
proc.stdin.write((json.dumps(events) + "\n").encode())
|
||||
proc.stdin.flush()
|
||||
proc.stdin.write((json.dumps(events) + "\n").encode())
|
||||
proc.stdin.flush()
|
||||
sent = True
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
pass # _watch reports how it ended
|
||||
@@ -912,115 +874,6 @@ def remote_input(body):
|
||||
return _input.send([input_event(e) for e in events])
|
||||
|
||||
|
||||
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
|
||||
|
||||
PANEL_WINDOW = re.compile(r"^\d{1,10}$")
|
||||
PANEL_DISPLAY = re.compile(r"^:\d{1,2}$")
|
||||
TOUCH_BUTTONS = ("left", "right", "middle")
|
||||
|
||||
|
||||
def panels():
|
||||
"""The headset's app panels (window, display, name, size) and which has focus."""
|
||||
return json.loads(ssh("python3 - panels", stdin=(HERE / "frame_touch.py").read_text(), timeout=20))
|
||||
|
||||
|
||||
def panel_target(q):
|
||||
window, display = (q.get("window") or [""])[0], (q.get("display") or [""])[0]
|
||||
if not PANEL_WINDOW.match(window) or not PANEL_DISPLAY.match(display):
|
||||
raise Failure("window must be a window id and display an X display such as :1", 400)
|
||||
return window, display
|
||||
|
||||
|
||||
def panel_capture(query):
|
||||
"""One frame of a panel's own window, as PNG (its pixels, without the room around it)."""
|
||||
window, display = panel_target(parse_qs(query))
|
||||
return ssh(f"DISPLAY={display} timeout 10 ffmpeg -hide_banner -loglevel error -nostdin -f x11grab "
|
||||
f"-window_id {window} -i {display} -frames:v 1 -f image2pipe -c:v png -", timeout=20, text=False)
|
||||
|
||||
|
||||
def touch_event(event):
|
||||
"""A frame_touch.py event with only the fields it knows, in range."""
|
||||
if not isinstance(event, dict):
|
||||
raise Failure("each touch event must be an object", 400)
|
||||
|
||||
def num(name, limit):
|
||||
value = event.get(name)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
|
||||
raise Failure(f"{name} must be a number", 400)
|
||||
return max(-limit, min(limit, round(float(value), 4)))
|
||||
out = {}
|
||||
if "fx" in event or "fy" in event:
|
||||
if "window" not in event:
|
||||
raise Failure("a position needs the panel's window and display", 400)
|
||||
out.update(fx=num("fx", 1), fy=num("fy", 1))
|
||||
# Any event can name the panel it's meant for; the Frame drops it if another has focus.
|
||||
if "window" in event:
|
||||
window, display = event.get("window"), event.get("display")
|
||||
if isinstance(window, bool) or not isinstance(window, int) or window <= 0:
|
||||
raise Failure("window must be the panel's window id", 400)
|
||||
if not isinstance(display, str) or not PANEL_DISPLAY.match(display):
|
||||
raise Failure("display must be an X display such as :1", 400)
|
||||
out.update(window=window, display=display)
|
||||
for name in ("dx", "dy"):
|
||||
if name in event:
|
||||
out[name] = num(name, INPUT_MOVE_LIMIT)
|
||||
if "button" in event:
|
||||
if event["button"] not in TOUCH_BUTTONS:
|
||||
raise Failure(f"button must be one of {', '.join(TOUCH_BUTTONS)}", 400)
|
||||
out["button"], out["down"] = event["button"], event.get("down") is not False
|
||||
if "scroll" in event:
|
||||
sc = event["scroll"]
|
||||
if not isinstance(sc, list) or len(sc) != 2:
|
||||
raise Failure("scroll must be [dx, dy]", 400)
|
||||
out["scroll"] = [num_value(v, 5000) for v in sc]
|
||||
if "key" in event:
|
||||
key = event["key"]
|
||||
if isinstance(key, bool) or not isinstance(key, int) or not 0 < key < 768:
|
||||
raise Failure("key must be a Linux key code", 400)
|
||||
out["key"], out["down"] = key, event.get("down") is not False
|
||||
if "text" in event:
|
||||
text = event["text"]
|
||||
if not isinstance(text, str) or not 0 < len(text) <= INPUT_TEXT_LIMIT:
|
||||
raise Failure(f"text must be 1 to {INPUT_TEXT_LIMIT} characters", 400)
|
||||
out["text"] = text
|
||||
if not set(out) - {"window", "display"}:
|
||||
raise Failure("touch event has nothing to do", 400)
|
||||
return out
|
||||
|
||||
|
||||
def num_value(value, limit):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
|
||||
raise Failure("scroll values must be numbers", 400)
|
||||
return max(-limit, min(limit, round(float(value), 2)))
|
||||
|
||||
|
||||
class TouchAgent(InputAgent):
|
||||
"""frame_touch.py on the Frame, fed events over one long-lived ssh. Nothing to install:
|
||||
it uses gamescope's own input socket and the libei that's on the image."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(source=HERE / "frame_touch.py", packages=[])
|
||||
|
||||
def deliver(self, report, force=False):
|
||||
return ""
|
||||
|
||||
def command(self, folder=""):
|
||||
code = base64.b64encode(self.source.read_bytes()).decode()
|
||||
return "python3 -u -c " + shlex.quote(
|
||||
f"import base64;exec(compile(base64.b64decode('{code}'),'frame_touch','exec'))")
|
||||
|
||||
|
||||
_touch = TouchAgent()
|
||||
|
||||
|
||||
def remote_touch(body):
|
||||
"""{"events": [...]} points, clicks, scrolls and types into the focused panel."""
|
||||
events = body.get("events", [])
|
||||
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
|
||||
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
|
||||
return _touch.send([touch_event(e) for e in events])
|
||||
|
||||
|
||||
def flatpak(body):
|
||||
app, action = str(body.get("id", "")), body.get("action")
|
||||
if not FLATPAK_ID.match(app):
|
||||
@@ -1789,32 +1642,6 @@ def _sweep_one(prefix, d):
|
||||
pass
|
||||
|
||||
|
||||
# ---- Panel switcher (same helper on the companion and in the headset) ----
|
||||
|
||||
def panels_action(body):
|
||||
action = body.get("action", "list")
|
||||
script = (HERE / "frame_panels.py").read_text()
|
||||
if action == "open":
|
||||
# Installed in the user account so the page can outlive this SSH call.
|
||||
remote = ('umask 077; mkdir -p ~/.local/share/frame-control/panels && '
|
||||
'tmp=$(mktemp ~/.local/share/frame-control/panels/install.XXXXXX) && '
|
||||
'cat > "$tmp" && mv "$tmp" ~/.local/share/frame-control/panels/switcher.py && '
|
||||
'python3 ~/.local/share/frame-control/panels/switcher.py --open')
|
||||
elif action == "list":
|
||||
remote = "python3 -"
|
||||
elif action == "focus":
|
||||
key = body.get("key")
|
||||
if not isinstance(key, str) or not frame_panels.KEY.fullmatch(key):
|
||||
raise Failure("Choose an open panel.", 400)
|
||||
remote = "python3 - --focus " + shlex.quote(key)
|
||||
else:
|
||||
raise Failure("Unknown panel action", 400)
|
||||
result = json.loads(ssh(remote, stdin=script, timeout=45))
|
||||
if "error" in result:
|
||||
raise Failure(result["error"], 502)
|
||||
return result
|
||||
|
||||
|
||||
# ---- Our Frame-side media player -----------------------------------------
|
||||
|
||||
_MEDIA_LOCK = threading.Lock()
|
||||
@@ -1950,14 +1777,14 @@ def agent_approval(body):
|
||||
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
|
||||
|
||||
|
||||
POST = {"/api/vr": vr, "/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
||||
POST = {"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
||||
"/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||
"/api/input": remote_input,
|
||||
"/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,
|
||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action}
|
||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action}
|
||||
|
||||
|
||||
# ---- HTTP ------------------------------------------------------------------
|
||||
@@ -2086,10 +1913,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(title_job(url.query))
|
||||
elif path == "/api/licenses":
|
||||
self.send_json({"notices": licenses()})
|
||||
elif path == "/api/panels":
|
||||
self.send_json(panels())
|
||||
elif path == "/api/touch":
|
||||
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
|
||||
elif path == "/api/input":
|
||||
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
|
||||
elif path == "/api/job":
|
||||
@@ -2101,8 +1924,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"shared": frame_catalog.compat_db.shared()})
|
||||
elif path == "/api/android/catalog":
|
||||
self.send_json({"apps": frame_catalog.catalog()})
|
||||
elif path == "/api/vr/utilities":
|
||||
self.send_json(frame_utilities.catalogue(steam_frame("utilities")["utilities"], frame_compat_db.load()))
|
||||
elif path == "/api/macview":
|
||||
self.send_json(macview_state(parse_qs(url.query)))
|
||||
elif path == "/api/telemetry":
|
||||
@@ -2123,8 +1944,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_bytes(*shot_image(url.query))
|
||||
elif path == "/api/stream":
|
||||
self.stream_video(url.query)
|
||||
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["panel"]:
|
||||
self.send_bytes(panel_capture(url.query), "image/png", headers=[("X-Capture-Source", "panel")])
|
||||
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
|
||||
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
|
||||
elif path == "/api/screenshot":
|
||||
@@ -2201,7 +2020,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
proc.wait()
|
||||
errors.seek(0)
|
||||
err = strip_ansi(errors.read().decode(errors="replace")).strip()
|
||||
raise Failure(err or "The Frame sent no video for 20 s")
|
||||
raise Failure(err or "The headset view sent no video for 20 s")
|
||||
chunk = first
|
||||
try:
|
||||
self.send_response(200)
|
||||
|
||||
Reference in new issue
Block a user