mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-18 03:01:34 +02:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
342bf78b05 | ||
|
|
16b37bc667 | ||
|
|
5b531c84fb | ||
|
|
ae8cc1b4a6 | ||
|
|
e89912d936 | ||
|
|
4c27a3397c | ||
|
|
63017e1969 | ||
|
|
b51fab9872 | ||
|
|
6aab62983e | ||
|
|
4ecc03280e | ||
|
|
2f1aeaac72 | ||
|
|
8347c2c3f7 | ||
|
|
a94c45d014 | ||
|
|
e20225604d | ||
|
|
2d81c92114 | ||
|
|
fe73906f1f | ||
|
|
84912ea4e1 | ||
|
|
73b97fedc5 | ||
|
|
924e37aa30 | ||
|
|
bd24436fbc | ||
|
|
895cc0ebbd | ||
|
|
f7dbad500a | ||
|
|
3f20e5f9d3 | ||
|
|
d6fb5e917a | ||
|
|
8051687e1e | ||
|
|
8fb00b263c | ||
|
|
4937caf310 | ||
|
|
128e7a4c94 | ||
|
|
daf30515b1 | ||
|
|
1a439f05c2 | ||
|
|
2f056dbcff | ||
|
|
016d2b8c2d | ||
|
|
e3176a2de8 | ||
|
|
42ec68ed51 | ||
|
|
fa49fab5bf | ||
|
|
416f3181cc | ||
|
|
ce9e476ec7 |
No files matched your search
@@ -5,6 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# A newer push to the same pull request supersedes a run still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -17,7 +22,9 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
sh -n ui/local-bin/ssh
|
||||
@@ -84,6 +91,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: End-to-end tests
|
||||
run: scripts/e2e.sh
|
||||
@@ -2,14 +2,22 @@ name: release
|
||||
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that touch the app build the same installers as artifacts.
|
||||
# Pull requests that change how the installers are built (the Electron app, the
|
||||
# macOS helper it compiles, or this workflow) build the same installers as
|
||||
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||
# are tested by the checks workflow; run this one by hand to package them early.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -94,7 +102,9 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||
# Tagged builds are attached to the release above; only other builds need artifacts.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
path: |
|
||||
|
||||
@@ -219,12 +219,13 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [Eye tracking and heart rate](docs/tracking.md) | Our OpenXR → OSC bridge, BlueZ heart-rate panel and optional local session log; SlimeVR feasibility notes |
|
||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
<details>
|
||||
|
||||
+54
-1
@@ -58,8 +58,61 @@ form shows the pinned one ("Trusted on first use: …") so you can compare it.
|
||||
Re-adding an existing URL preserves its pin; changing it requires deliberately
|
||||
removing and re-adding it.
|
||||
|
||||
### Repositories behind a login
|
||||
|
||||
Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own
|
||||
client, the login can be part of the address
|
||||
(`https://user:pass@example.org/fdroid/repo`), or given separately: in the
|
||||
store's **Add a source** form under "This repository needs a login", in the
|
||||
server API (`username` and `password` beside `url`), or on the command line,
|
||||
where the password is read from standard input so it stays out of shell history
|
||||
and `ps`:
|
||||
|
||||
```sh
|
||||
printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin
|
||||
```
|
||||
|
||||
How the login is kept and used:
|
||||
|
||||
- **Kept out of the address.** The `user:pass@` part is removed before anything
|
||||
is saved, so the saved repository URL, its id, the source list, job results
|
||||
and the page never contain it. The store only says "Uses a login".
|
||||
- **Stored separately.** Logins live in
|
||||
`frame_host.data_dir('source-credentials.json')`
|
||||
(`~/Library/Application Support/Frame Control/source-credentials.json` on
|
||||
macOS), written with mode 0600 and keyed by source id. They are stored in
|
||||
plain text there, protected by file permissions only (on Windows,
|
||||
the folder's normal ACLs). Removing the repository deletes its login, and
|
||||
the file goes when the last login is removed. Each change holds a lock
|
||||
file (`source-credentials.lock`), so the app and the command line can't undo
|
||||
each other's saves or removals.
|
||||
- **HTTPS only.** An `http://` address with a login is refused before any request.
|
||||
- **Inside the repository only.** The `Authorization` header is decided
|
||||
separately for every request, redirects included: index, `entry.jar`,
|
||||
APKs and the store's artwork proxy. It is sent only when the request's
|
||||
scheme, host and port match the repository's and its path is inside the
|
||||
repository's directory by whole path segments. A redirect to another host
|
||||
or port, or to another path on the same server, gets no login, and that
|
||||
request's own `Authorization` header is removed. Paths with `.` or `..`
|
||||
segments (plain or percent-encoded), encoded `/` or `\`, double encoding
|
||||
or `;` get no login, since a server could normalise them to a path
|
||||
outside the repository. Index file names like that are refused outright.
|
||||
- **Never echoed.** Logins are not written to logs, error messages, API
|
||||
responses or the cache. The CLI prints only the saved source.
|
||||
- **Checked before saving.** As with any repository, the whole index must
|
||||
download and verify before anything is saved. HTTP 401 (or 403 when a login
|
||||
was sent) stops the add with "check the username and password" and saves
|
||||
nothing: no repository, no login, no rollback state. A 403 with a login is
|
||||
not treated as rate limiting.
|
||||
- **Same verification.** The login changes only who may download. Signature,
|
||||
fingerprint pin or TOFU, index hash and rollback checks apply exactly as for
|
||||
any other repository.
|
||||
|
||||
Re-adding the same address with a new login replaces the saved one. Re-adding
|
||||
it without a login keeps the saved one.
|
||||
|
||||
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
|
||||
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
|
||||
`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`,
|
||||
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
|
||||
`sources`, `search`, `details`, and `download` from the shared source contract.
|
||||
This change supplies the CLI and API; the integrated source-management UI is
|
||||
|
||||
+17
-4
@@ -45,10 +45,23 @@ search. Results outside the curated list stay page-only, even if a repository
|
||||
claims an open-source license. This prevents an arbitrary tagged mirror from
|
||||
becoming a trusted downloader. Extend the curated JSON after provenance review.
|
||||
|
||||
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
|
||||
quota. Tokens are sent only to `api.github.com`, never written to the cache,
|
||||
never sent to asset hosts, and removed on redirects. The adapter does not
|
||||
read `gh` credentials automatically. Metadata is cached for one hour under
|
||||
For a higher API quota, add a GitHub access token in the store's source
|
||||
settings (the GitHub row's token field) or through the server API
|
||||
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
|
||||
`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still
|
||||
works and wins over a saved token. A saved token is kept with repository
|
||||
logins in `source-credentials.json` (mode 0600, see
|
||||
[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list
|
||||
reports only whether a token is set, where it came from and whether GitHub
|
||||
rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to
|
||||
`api.github.com`, never written to the cache, never sent to asset hosts (public
|
||||
release downloads don't need them), and removed on redirects. If GitHub answers
|
||||
401 to the token, the request is retried once without it, as Obtainium does.
|
||||
The rejection is logged without the token and shown in source settings. The
|
||||
token is not tried again until it is saved again or the app restarts. Rate-limit
|
||||
backoff is kept per login (anonymous, or each token), so saving a token after
|
||||
anonymous requests were limited lets the next request go out with it. The
|
||||
adapter does not read `gh` credentials automatically. Metadata is cached for one hour under
|
||||
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
|
||||
fetches at most ten releases. Rate-limit errors are surfaced without retry
|
||||
loops. Asset IDs and release tags are not Android version codes: metadata
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# UEVR from Frame Control, 2026-09-29
|
||||
|
||||
**Verified observations**, with limits below. Same device and software as
|
||||
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
|
||||
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
|
||||
Frame's AEST clock. Headset tests ran under the shared
|
||||
`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40,
|
||||
12:44–12:52 and 13:01–13:03. Battery 45 % →
|
||||
93 %, on charge throughout.
|
||||
|
||||
**Not observed:** the VR image, head tracking and controller input. SteamVR
|
||||
reported `activity_level` 3 (user interaction timeout; the headset was not
|
||||
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
|
||||
capture was a uniform dark frame. Frame submits prove the app is presenting
|
||||
to SteamVR, not that the image is correct.
|
||||
|
||||
## Manual injection (11:13–11:28)
|
||||
|
||||
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
|
||||
started with the game process's Wine variables, and its WPF window drew
|
||||
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
|
||||
(64-bit, PCD3D_SM5))`, a Wine-side process id.
|
||||
|
||||
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
|
||||
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
|
||||
was not restored on the next start.
|
||||
- XTest clicks landed on the game: `XQueryPointer` reported child
|
||||
`0x2000001` (the game) at the injector's coordinates, and
|
||||
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
|
||||
- `XSendEvent` button events to the injector window worked. After OpenVR and
|
||||
Inject, `/proc/<game>/maps` contained
|
||||
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
|
||||
Proton's `vrclient_x64.dll`. UEVR's log:
|
||||
|
||||
```text
|
||||
[11:17:32.948] Hooking D3D11
|
||||
[11:17:33.020] Hooked DirectX 11
|
||||
[11:17:34.263] Framework initialized
|
||||
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
|
||||
[11:17:39.791] UGameViewportClient::Draw called for the first time.
|
||||
[11:17:39.792] is stereo enabled called!
|
||||
```
|
||||
|
||||
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
|
||||
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
|
||||
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
|
||||
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
|
||||
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
|
||||
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
|
||||
|
||||
A second session's injector exited within about 20 seconds. The third,
|
||||
started fresh, injected again (`frame_submits` 1412). After the game was
|
||||
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
|
||||
files were removed.
|
||||
|
||||
## Through Frame Control (11:33–11:49)
|
||||
|
||||
Local `ui/server.py` against the `frame` alias. First the API, then the
|
||||
page's **VR mod** dialog, driven with a headless Chromium:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
|
||||
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
|
||||
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
|
||||
| `uninstall` while running | Refused: "the game is running; quit it first" |
|
||||
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
|
||||
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
|
||||
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) |
|
||||
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
|
||||
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
|
||||
|
||||
## The GUI injector wasn't reliable (12:02–12:40)
|
||||
|
||||
After review fixes, cold starts through the API with UEVR's own injector:
|
||||
|
||||
- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
|
||||
relaunched the injector didn't rescue a bad session. In both failures of
|
||||
the last batch, the first injector ignored the clicks and the next two
|
||||
never showed a window within 60 s.
|
||||
- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
|
||||
with OpenVR still unselected. The injector's log had no error.
|
||||
|
||||
Lock released at 12:40 before redesigning.
|
||||
|
||||
## Frame Control's own injector (12:44–12:52)
|
||||
|
||||
`start` now runs `frame_inject.py` under python.org's embeddable Python
|
||||
3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
|
||||
the game's Wine session. The old install was removed and the new one
|
||||
installed without the headset lock, since neither launches anything. The
|
||||
prefix listing after removal matched the pre-install state; the new install
|
||||
is 47 MB.
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
|
||||
| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
|
||||
| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
|
||||
| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
|
||||
| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
|
||||
|
||||
Battery 98–100 % on charge. Lock released at 12:52.
|
||||
|
||||
## After the second review (13:01–13:03)
|
||||
|
||||
`frame_inject.py` now declares every Win32 signature, checks each result,
|
||||
frees its remote buffers and closes its handles; uninstall accepts only our
|
||||
folder and UEVR's own settings folders. Rechecked: install; three cold starts
|
||||
injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging
|
||||
`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall
|
||||
left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache,
|
||||
receipts or processes. Frame Control's injector total: 12 of 12 cold starts
|
||||
through the API, plus the UI run.
|
||||
|
||||
## Left on the Frame
|
||||
|
||||
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
|
||||
installed; nothing else from this work is left.
|
||||
- No `UEVRInjector.exe` or game processes were left running. Steam and
|
||||
SteamVR were running at the end.
|
||||
- The test lock was released at 13:03.
|
||||
@@ -20,6 +20,15 @@ SteamVR (vrserver, vrcompositor, vrdashboard) ← renders the room + pane
|
||||
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000
|
||||
```
|
||||
|
||||
## Tracking additions (verified 2026-09-28)
|
||||
|
||||
On SteamOS 0.4.1, build `20260925.6191901`, SteamVR exposes combined gaze
|
||||
through `XR_EXT_eye_gaze_interaction` in a headless OpenXR 1.0 session. Our
|
||||
reader obtained valid tracked samples and sent OSC to a configured loopback
|
||||
receiver. BlueZ LE discovery works; GTK4/GI can render our heart-rate panel.
|
||||
No BLE strap or SlimeVR trackers were attached. See [tracking](tracking.md)
|
||||
for the evidence, privacy defaults and untested integration boundaries.
|
||||
|
||||
## Facts worth knowing
|
||||
|
||||
| Fact | Where it matters |
|
||||
@@ -52,6 +61,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
||||
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
||||
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
|
||||
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
+85
-15
@@ -1,11 +1,14 @@
|
||||
# Flat-to-VR mods and Beat Saber songs
|
||||
|
||||
**Status: feasibility work, not an installer.** Frame Control does not yet
|
||||
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
|
||||
stays open: neither UEVR injection nor Beat Saber custom-song playback has
|
||||
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
|
||||
has not been inspected. There is no Mods button until the underlying
|
||||
install, playback and removal have been checked.
|
||||
**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
|
||||
songs are blocked.** Installed games have a **VR mod** button. It installs
|
||||
UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
|
||||
loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
|
||||
UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
|
||||
the VR image, head tracking and controls haven't been seen yet. Beat Saber
|
||||
custom songs need an owned Beat Saber on the Frame, which there isn't
|
||||
([below](#beat-saber-songs-first)).
|
||||
[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those.
|
||||
|
||||
The mod manager must be Frame Control's own implementation. Mods and songs
|
||||
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
|
||||
@@ -15,27 +18,87 @@ unofficial mod mirrors are part of this work.
|
||||
|
||||
## Per-game support
|
||||
|
||||
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
|
||||
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
|
||||
**20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
the observation stated, not a promise that the game is playable. “Documented”
|
||||
means an upstream source describes it; “inferred” means it still needs a test.
|
||||
|
||||
| Game / build | Mod or content | Evidence and support status | Next check |
|
||||
|---|---|---|---|
|
||||
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
|
||||
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
|
||||
| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. |
|
||||
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
|
||||
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
|
||||
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
|
||||
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
|
||||
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
|
||||
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
|
||||
|
||||
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
|
||||
visible output and failures. It also records a SteamVR restart during the
|
||||
shared session, which prevents attributing the failed UEVR attempt to FEX.
|
||||
The test records ([2026-09-28](evidence/mods-2026-09-28.md),
|
||||
[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
|
||||
visible output, injection and failures.
|
||||
|
||||
## UEVR from Frame Control
|
||||
|
||||

|
||||
|
||||
**Get games → an installed game → VR mod.** The dialog says whether the game
|
||||
can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove
|
||||
UEVR**. The server route is `POST /api/mods {"action": "status" | "install" |
|
||||
"start" | "uninstall", "appid": …}`, and the work happens on the Frame in
|
||||
[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library,
|
||||
like `frame_steam.py`.
|
||||
|
||||
- **Which games.** Unreal Engine games, recognised by their
|
||||
`…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked
|
||||
with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
|
||||
prefix, so it must have been played once.
|
||||
- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
|
||||
Each is checked against a pinned SHA-256; Python's comes from python.org's
|
||||
release-file API. Archives are unpacked into a staging folder, rejecting
|
||||
entries that escape it, symlinks and oversized contents, then moved into
|
||||
`<prefix>/drive_c/frame-control/` (about 47 MB). Nothing goes into the
|
||||
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists
|
||||
what to remove, including UEVR's own settings folders only if they didn't
|
||||
exist before. Install and remove refuse while the game is running, and only
|
||||
one mod action runs at a time.
|
||||
- **Play in VR.** Launches the game through Steam if it isn't running and
|
||||
waits for its window. It then runs a short script under that Python, with
|
||||
the game's Proton, prefix and Wine settings read from the game process, so
|
||||
both share one Wine session. The script repeats what UEVR's own injector does
|
||||
when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
|
||||
call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in
|
||||
UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a
|
||||
`LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start`
|
||||
then checks that `UEVRBackend.dll` is mapped into the game and reads
|
||||
SteamVR's frame count for the app (`vrcmd --stats`).
|
||||
- **Remove.** Deletes what the receipt lists, then the download cache once
|
||||
no game uses it. Verified: the prefix's file listing matched its
|
||||
pre-install state afterwards.
|
||||
|
||||
Why not UEVR's own injector (verified 2026-09-29):
|
||||
|
||||
- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
|
||||
of downloads) in the prefix.
|
||||
- Under gamescope, the full-screen game keeps the pointer and focus. XTest
|
||||
clicks, raising or refocusing the injector window don't change that, so in
|
||||
the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
|
||||
events straight to its window does work.
|
||||
- `--attach=<process>` didn't inject under Wine, and it didn't restore its
|
||||
saved OpenVR choice.
|
||||
- Driven that way, it injected in 6 of 8 cold starts. In a bad session every
|
||||
new injector ignored input or never showed its window, even after
|
||||
relaunching. Its process list reads each process's main window title, a
|
||||
cross-process window message; a window that isn't answering would block its
|
||||
UI thread (inferred from the frontend's source, not proven). Without a
|
||||
`HOME` it also died in .NET at startup.
|
||||
- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI.
|
||||
- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for
|
||||
Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
|
||||
Neither stopped the injection.
|
||||
|
||||
## Beat Saber: songs first
|
||||
|
||||
@@ -62,7 +125,10 @@ or dependency for Frame Control.
|
||||
|
||||
## Requirements for our manager
|
||||
|
||||
These are **planned**, not implemented or verified:
|
||||
For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by
|
||||
fake-library tests (`tests/test_mods.py`), apart from checking ownership
|
||||
through Steam; item 6 is met for Gravitas. Beat Saber songs would need the
|
||||
same, and none of it is built for them:
|
||||
|
||||
1. Resolve the selected Steam game's real library, installed build, executable
|
||||
architecture and Proton prefix. Confirm ownership through Steam; a directory
|
||||
@@ -91,7 +157,11 @@ These are **planned**, not implemented or verified:
|
||||
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
|
||||
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
|
||||
including the SHA-512 hashes used for the test runtimes.
|
||||
including the SHA-512 hashes used for the injector tests.
|
||||
- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs`
|
||||
(`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows.
|
||||
- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the
|
||||
embeddable Python's `sha256_sum`.
|
||||
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
|
||||
including per-game installation and the need to preserve original DLLs.
|
||||
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's
|
||||
Steam Frame rating, installs them on the Frame, and searches the Steam store.
|
||||
This page covers how it works underneath, so you can do the same from a shell.
|
||||
|
||||
For flat-to-VR mods and Beat Saber custom songs, see the
|
||||
[per-game feasibility table](mods.md). Mod support is separate from Steam's
|
||||
Frame rating; there is no mod installer yet.
|
||||
Installed Unreal Engine games also have a **VR mod** button that installs,
|
||||
starts and removes UEVR; see [VR mods](mods.md) for how it works and the
|
||||
per-game table. Mod support is separate from Steam's Frame rating.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -21,6 +21,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
|
||||
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
|
||||
|
||||
**RDP from Windows, verified 2026-09-30:** Windows 11 25H2's Remote Desktop
|
||||
(`mstsc`) against BUILD_ID 20260925.6191901. xrdp picks TLS, not NLA, so
|
||||
Remote Desktop never asks for a user or password. It warns that the certificate
|
||||
(`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to
|
||||
frame" box with the username blank. Any user but `steamos` gets "User does not
|
||||
exist, or could not be authenticated". Signing in as `steamos` with the
|
||||
Developer Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||
It's a new session on display `:10`, separate from what the headset shows,
|
||||
and it uses about 1.3 GB of the Frame's memory. Closing Remote Desktop leaves
|
||||
it running, and the next sign-in reconnects to it. To end it, find its
|
||||
session with `loginctl list-sessions` over SSH (its leader is `xrdp-sesexec`)
|
||||
and run `loginctl terminate-session <id>`; the headset's own session keeps
|
||||
running.
|
||||
|
||||
**Recommendation for A:** start with Steam Link for macOS, because Valve
|
||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||
Mac with keyboard, mouse, and clipboard.
|
||||
|
||||
@@ -8,6 +8,7 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
|
||||
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
|
||||
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
|
||||
| [Windows test VM](#windows-test-vm) | `scripts/windows-vm.sh` | A Linux machine with KVM and Docker | The Windows build on a real Windows desktop, against a real Frame when needed |
|
||||
|
||||
## Unit tests
|
||||
|
||||
@@ -186,6 +187,93 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
|
||||
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||
Steam accepts.
|
||||
|
||||
## Tracking protocols and fake BlueZ
|
||||
|
||||
`tests/test_tracking.py` exercises our gaze conversion, OSC sender, HRS parser
|
||||
and BlueZ lifecycle with an in-memory fake object tree. It runs in the normal
|
||||
unit suite without Bluetooth, GTK or OpenXR. Real Frame results and the absent
|
||||
strap/tracker boundaries are recorded in [tracking](tracking.md).
|
||||
|
||||
## Windows test VM
|
||||
|
||||
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||
depends on the Windows desktop (Remote Desktop, the installer, the bundled
|
||||
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
|
||||
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
|
||||
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
|
||||
|
||||
Setting it up, once, on the Linux machine:
|
||||
|
||||
- **Windows comes from Microsoft.** The container downloads the Windows 11
|
||||
image from Microsoft on first start. Windows runs unactivated, which is fine
|
||||
for testing. Don't use activation workarounds or third-party Windows images.
|
||||
- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH),
|
||||
`127.0.0.1:8006:8006` (the web console) and, if you need it,
|
||||
`127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use
|
||||
`restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is
|
||||
testing, and a `docker stop` shuts Windows down cleanly.
|
||||
- **Give it SSH on first sign-in.** The container runs `/oem/install.bat`
|
||||
once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell
|
||||
as its default shell, and put a dedicated public key (for example
|
||||
`~/.ssh/id_ed25519_winvm` on the Mac) in
|
||||
`C:\ProgramData\ssh\administrators_authorized_keys`.
|
||||
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
|
||||
and the tests.
|
||||
|
||||
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
|
||||
|
||||
```sh
|
||||
scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes)
|
||||
scripts/windows-vm.sh put Frame-Control-Setup-x64.exe
|
||||
scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait'
|
||||
scripts/windows-vm.sh shot screen.png # what's on its screen
|
||||
scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot
|
||||
scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names
|
||||
scripts/windows-vm.sh down # shut Windows down
|
||||
```
|
||||
|
||||
- **Clicks go through a scheduled task.** Commands over SSH run in a
|
||||
session with no desktop, so `click` and `scroll` write the position to a
|
||||
file, and a scheduled task running as the signed-in user replays it. The
|
||||
VM's screen must be signed in; it is after `up`. Clicks and scrolls sent at
|
||||
the same time run one after another. QEMU's own `mouse_move` is relative
|
||||
and drifts, so the script doesn't use it.
|
||||
- **Screenshots may not show the pointer.** Check the result of a click (a
|
||||
menu that opens, a button that changes) rather than the pointer's position.
|
||||
- **Windows' `ssh` waits for stdin.** The script closes it for every command.
|
||||
Do the same if you run `ssh` in the VM by hand.
|
||||
- **Starting the app.** Run Frame Control in the signed-in session, not over
|
||||
SSH. Use its Start-menu shortcut via `click`, or a scheduled task like the
|
||||
one `click` uses.
|
||||
|
||||
**Testing against a real Frame.** The VM reaches the headset on the LAN like
|
||||
any other computer. Run **Set Up Connection** in the VM's Frame Control once;
|
||||
that makes the VM's keys. So the VM doesn't keep access between test runs,
|
||||
afterwards take the lines it added out of the headset's
|
||||
`~/.ssh/authorized_keys`: the ones matching the VM's
|
||||
`~/.ssh/id_ed25519_frame.pub` and, if pairing made one,
|
||||
`~/.ssh/id_rsa_frame_devkit.pub`. Check that `ssh -o BatchMode=yes frame true`
|
||||
in the VM now fails. Then, around each run:
|
||||
|
||||
```sh
|
||||
scripts/windows-vm.sh frame-key add # let the VM's key into the headset
|
||||
# ... test ...
|
||||
scripts/windows-vm.sh frame-key remove # and take it out again
|
||||
```
|
||||
|
||||
`add` appends the VM's key tagged `windows-vm-test`, unless the headset
|
||||
already trusts that key through another line. `remove` deletes only the exact
|
||||
line `add` wrote, so it doesn't undo what Set Up Connection did, and it leaves
|
||||
the file alone if it can't rewrite it. Follow the shared-device
|
||||
procedure in [Headset smoke test](#headset-smoke-test) before installing or
|
||||
launching anything on the headset.
|
||||
|
||||
**Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on
|
||||
BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0,
|
||||
connect it to the Frame and follow Remote Desktop through to the Frame's
|
||||
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
|
||||
`frame-key` left `authorized_keys` byte-for-byte as it was.
|
||||
|
||||
## Owned media player
|
||||
|
||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
# Eye tracking and heart rate
|
||||
|
||||
Frame Control's own tools run on the Frame, using OpenXR and BlueZ. No
|
||||
VRCFaceTracking, LunaHR, Pulsoid or other tracking app is required. This is a
|
||||
command-line first version; it does not add a desktop app tab.
|
||||
|
||||
## What was checked
|
||||
|
||||
**Verified 2026-09-28**, on a real aarch64 Frame running SteamOS 0.4.1,
|
||||
BUILD_ID `20260925.6191901`:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| OpenXR gaze | SteamVR advertises `XR_EXT_eye_gaze_interaction`, `XR_MND_headless` and `XR_KHR_convert_timespec_time`. `supportsEyeGazeInteraction=1`. A headless session reached FOCUSED and produced 269 valid, tracked orientations in the first ten-second probe. |
|
||||
| Our gaze → OSC bridge | A separate ten-second run produced 280 valid samples and 280 correctly padded 44-byte `/tracking/eye/CenterPitchYaw` messages at an explicitly configured loopback receiver. Only counters and packet-layout checks were retained. |
|
||||
| Bluetooth stack | BlueZ active, adapter powered, central/peripheral roles available. LE discovery started and stopped successfully. No pairing or adapter power settings changed. |
|
||||
| Our heart-rate panel | GTK4/GI runs on the stock image. A **synthetic 72 BPM** notification displayed in our X11 window, tagged `STEAM_GAME=2000000027`. Window capture checked; no real heart-rate measurement was taken. |
|
||||
| SlimeVR, separate feasibility check | Native aarch64 server v21.1.0 ran with an isolated Temurin 21 JRE, created its driver sockets and accepted a local TCP connection on port 21110. Driver v6.0.0 loaded with all shared libraries resolved; `HmdDriverFactory("IServerTrackedDeviceProvider_004")` returned a non-null provider and error 0. |
|
||||
|
||||

|
||||
|
||||
The image is a capture of our own Frame window using a fake notification,
|
||||
not a real sensor reading.
|
||||
|
||||
**Untested:** a real BLE strap's notifications, physical fit/contact behaviour,
|
||||
end-to-end heart-rate display/OSC/log with a strap, avatar response in VRChat,
|
||||
gaze accuracy/calibration, coexistence with every immersive app, in-headset
|
||||
panel placement, SlimeVR tracker/calibration data and the SlimeVR driver running
|
||||
inside SteamVR. No trackers or strap are attached. The driver was loaded in a
|
||||
separate process; it was **not registered or activated in SteamVR**. Steam and
|
||||
SteamVR were not stopped or restarted.
|
||||
|
||||
**Verified blocker resolved:** importing `tkinter` fails because `libtk8.6.so`
|
||||
is absent. The panel uses the installed GTK4/GI bindings instead. The Frame's
|
||||
OpenXR headers advertise a newer API version than the runtime accepts; our
|
||||
reader requests OpenXR 1.0 explicitly.
|
||||
|
||||
## Install our tools
|
||||
|
||||
From this checkout on your computer, while the Frame is awake:
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py install
|
||||
```
|
||||
|
||||
This copies our Python code and compiles our small C OpenXR reader into
|
||||
`~/.local/share/frame-control/tracking/` on the Frame. It uses the Frame's
|
||||
existing compiler, OpenXR headers/loader, Python, dbus-python, GI and GTK4.
|
||||
Nothing is downloaded, and no sudo, driver registration, system setting,
|
||||
service or autostart is added. `FRAME_ALIAS` can select another SSH alias.
|
||||
The desktop app/server keeps its existing stdlib-only dependency set.
|
||||
|
||||
## Eye tracking → OSC
|
||||
|
||||
Start with a ten-second capability/data-availability check:
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py gaze --seconds 10
|
||||
```
|
||||
|
||||
This prints support, session-state numbers and sample counters. It opens no
|
||||
OSC socket and prints no gaze coordinates. Exit 0 means at least one valid
|
||||
sample, 3 means no valid sample was observed, and 1 means an API/runtime error.
|
||||
If there are no valid samples, wake/wear the headset and check its tracking
|
||||
setup; a successful capability check alone does not prove usable gaze.
|
||||
|
||||
To send to VRChat running **on the Frame**, explicitly enable OSC in VRChat
|
||||
and choose its local UDP endpoint:
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000
|
||||
```
|
||||
|
||||
For a receiver on another computer, replace `127.0.0.1` with that computer's
|
||||
IP address and choose its listening port. Addresses are IP literals (IPv4 or
|
||||
IPv6); there is no discovery or default destination. Loopback here always
|
||||
means **the Frame**, not the computer running the SSH command. OSC uses
|
||||
unencrypted UDP: configure only a receiver you intend to receive this data.
|
||||
|
||||
**Documented:** [VRChat's eye OSC interface](https://docs.vrchat.com/docs/osc-eye-tracking)
|
||||
accepts `/tracking/eye/CenterPitchYaw` with two floats in degrees, positive down
|
||||
and right. We locate OpenXR's combined gaze pose relative to VIEW (the head),
|
||||
rotate its -Z forward vector and convert that direction to these angles.
|
||||
Only active, orientation-valid **and tracked** samples are sent, at up to
|
||||
30 Hz. No eyelid/blink, individual-eye or face values are invented. We do not
|
||||
send neutral gaze on tracking loss; VRChat's documented timeout restores its
|
||||
automatic eye behaviour after input stops.
|
||||
|
||||
**Privacy:** gaze is personal data. It stays in process memory and a private
|
||||
pipe between our reader and bridge. There is no gaze log option, telemetry,
|
||||
OSC receiver or raw gaze on stdout/stderr. Only an explicit `--osc IP PORT`
|
||||
opens an output socket. Runtime diagnostics and validity counters are not
|
||||
measurements. Stop with Ctrl-C or let `--seconds` expire (maximum 24 hours).
|
||||
A lost headless session ends the run; it does not silently reconnect.
|
||||
|
||||
## BLE heart rate → our panel, OSC and optional log
|
||||
|
||||
First discover/pair your strap in SteamOS's Bluetooth settings. Select that
|
||||
strap's Bluetooth address explicitly; our tool does not scan for or connect
|
||||
to arbitrary nearby devices.
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py heart \
|
||||
--device AA:BB:CC:DD:EE:FF --panel --seconds 3600
|
||||
```
|
||||
|
||||
This uses BlueZ's standard Heart Rate Service (`180d`) and Heart Rate
|
||||
Measurement (`2a37`) notifications. It finds the characteristic only beneath
|
||||
the selected device's HRS service. The reader handles 8- and 16-bit BPM,
|
||||
contact flags and optional energy/RR fields; energy and RR intervals are
|
||||
validated for length but discarded. Zero BPM, reported loss of skin contact,
|
||||
malformed packets and readings older than five seconds are not shown as a
|
||||
current measurement. A disconnect stops the run; reconnect and start again.
|
||||
This is a social/fitness readout, not a medical monitor.
|
||||
|
||||
The panel is our GTK4 window on gamescope's X display. Use SteamVR's panel
|
||||
controls to float/dock it (see [panels](panels.md)). **Stop**, closing the panel,
|
||||
Ctrl-C, SSH hangup or the duration limit ends our subscription. A connection
|
||||
that was already open when we started is preserved; a connection we opened
|
||||
is disconnected on exit. No Bluetooth power or pairing state is changed.
|
||||
|
||||
Add either output explicitly:
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py heart \
|
||||
--device AA:BB:CC:DD:EE:FF --panel --seconds 3600 \
|
||||
--osc 127.0.0.1 9000 --address /avatar/parameters/HeartRate \
|
||||
--log /home/steamos/heart-session.csv
|
||||
```
|
||||
|
||||
The OSC value is integer BPM. `HeartRate` is a chosen avatar parameter, **not a
|
||||
built-in VRChat heart-rate feature**; your avatar/receiver must define the
|
||||
matching parameter. `--address` can select another literal OSC path. The local
|
||||
panel works without OSC, a log or an avatar integration.
|
||||
|
||||
The optional CSV contains only `unix_seconds,bpm`. It is created privately
|
||||
(mode 0600), refuses existing files/symlinks, and lives **on the Frame** at the
|
||||
path you specify. Nothing is logged by default, and heart-rate values are not
|
||||
printed to the terminal. Delete your session file when you no longer need it.
|
||||
|
||||
### Checking heart rate against a reference
|
||||
|
||||
`scripts/heart-check.py` runs on your computer. `listen` shows our OSC
|
||||
readings live as they arrive, so you can watch them next to another device:
|
||||
|
||||
```sh
|
||||
python3 scripts/heart-check.py listen --port 9000 --out ours.csv
|
||||
```
|
||||
|
||||
Point the Frame at it with `--osc <your computer's IP> 9000`. `compare` lines
|
||||
up two recordings by time and reports the mean difference, bias, the share
|
||||
within ±5 BPM and the delay between them. It passes when the mean difference
|
||||
is at most 5 BPM, at least 80% of reference readings are matched and nothing
|
||||
was shown while the sensor reported lost skin contact:
|
||||
|
||||
```sh
|
||||
python3 scripts/heart-check.py compare ours.csv reference.csv
|
||||
python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip
|
||||
```
|
||||
|
||||
The reference can be a CSV (`time,bpm[,flags]`, time in unix seconds or ISO
|
||||
8601) or an Apple Health export (`export.zip` or `export.xml`). Only heart-rate
|
||||
records within the recording's time range are read. Everything stays on your
|
||||
computer.
|
||||
|
||||
`scripts/heart-test-strap.swift` turns a Mac into a synthetic strap. It
|
||||
advertises the standard Heart Rate Service and sends a fixed, known sequence
|
||||
(8-bit and 16-bit values and a skin-contact loss), printing each sent value, so
|
||||
`compare` can check that the Frame shows exactly what was sent. It needs
|
||||
Bluetooth permission for the process that runs it. **Untested on 2026-09-29:**
|
||||
it compiled, but on this Mac, launched from an agent session, macOS never
|
||||
delivered a Bluetooth state and no permission prompt appeared, so it never
|
||||
advertised.
|
||||
|
||||
## Pulse from the eye cameras (experimental)
|
||||
|
||||
The Frame has no heart-rate sensor. **Verified 2026-09-29** (SteamOS 0.4.1,
|
||||
build `20260925.6191901`): its sensors are an ambient light/proximity sensor
|
||||
(`vcnl4000`), a hall sensor (`als31300`), two passthrough cameras
|
||||
(`arcimx616`), two tracking cameras (`og01a1b`) and two IR eye cameras
|
||||
(`og0ve10`). There is no optical heart-rate (PPG) sensor.
|
||||
|
||||
The experiment asks whether the eye cameras can see a pulse anyway. With each
|
||||
heartbeat, the blood volume in the skin around the eye changes slightly and
|
||||
its IR reflectance changes with it. This is camera-based photoplethysmography;
|
||||
near-IR works, though the signal is weaker than in green light.
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py pulse --seconds 60 --show
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
- **Capture (verified).** SteamVR ships `eyetracking --calib N`, which saves
|
||||
both eye cameras for N seconds as 400×400 8-bit IR PNGs with a monotonic
|
||||
timestamp per frame, at about 90 fps per eye. SteamVR's live eye tracker,
|
||||
part of `steamvr.service`, gets its frames from the DSP and stops its
|
||||
cameras when the headset is off. Unworn captures ran alongside it: its PID
|
||||
and log were unchanged and our OpenXR gaze session still started
|
||||
afterwards. **Untested:** whether the capture and the live tracker coexist
|
||||
while the headset is worn and tracking.
|
||||
- **Privacy.** Each image is reduced to a 16×16 grid of patch averages as
|
||||
soon as it is complete, then deleted. Three worker processes do this beside
|
||||
the capture. If more than 900 images (about five seconds) ever wait, we stop
|
||||
reading them and delete them undecoded until the capture ends, and report
|
||||
an error. The capture directory is removed on exit, even after errors. No image is kept or leaves the Frame. The estimate
|
||||
is printed only with `--show`, and sent or saved only with `--osc` or
|
||||
`--log`, as for the strap.
|
||||
- **Estimate.** Patch traces are averaged down to 15 Hz and turned into
|
||||
relative change. A 2-second moving median removes drift and blinks.
|
||||
Patches with frequent spikes (the eyeball and eyelid) are dropped, as are
|
||||
dark or saturated ones. The 20% of patches with the clearest rhythm between
|
||||
42 and 180 BPM are combined in the frequency domain. Output is an overall
|
||||
estimate plus one estimate per second over 15-second windows. A result
|
||||
counts as **clear** only when the top patches agree and the combined signal
|
||||
stands out from the noise. Otherwise the command exits 3 and sends no OSC.
|
||||
`--log` still records the per-second estimates, so a comparison shows how
|
||||
far off an unclear result was.
|
||||
The thresholds are provisional until checked on real wearers.
|
||||
|
||||
**Verified on the Frame, unworn, 2026-09-29:** captures of 3,600-5,400 eye
|
||||
frames never had more than 8 images on disk, finished a few seconds after the
|
||||
capture ended and left no capture directory. **The estimator alone gave a
|
||||
false "clear" pulse.** With nobody wearing the headset, five runs reported a
|
||||
steady, self-consistent rhythm (90, 90, 93, 94 and 96 BPM; patch agreement
|
||||
100%, signal/noise 0.63-0.70), and earlier runs reported 127-129 BPM at lower
|
||||
signal/noise. It is a periodic camera or illumination artifact, and its
|
||||
frequency drifts between runs. A wearer-less scene cannot contain a pulse, so
|
||||
the signal/noise gate cannot tell this artifact from one. Because of that,
|
||||
`pulse` reads the Frame's proximity sensor (`vcnl4000`) before and after the
|
||||
capture. It reads about 3 unworn (**verified**). If either reading is below 20
|
||||
the result is never called clear, nothing is sent over OSC, and the command
|
||||
exits 3. **Inferred, unmeasured:** that a worn reading is well above 20; the
|
||||
cut-off is provisional until someone wears the headset. If the sensor can't be
|
||||
read, the guard is skipped. Confirming a real pulse also needs a reference
|
||||
(below).
|
||||
|
||||
**Do not interrupt the capture. Verified on the Frame, 2026-09-29:** sending
|
||||
SIGTERM to `eyetracking --calib` left the DSP service's eye camera (OV6211)
|
||||
stuck "streaming": its log had no "Stopping streaming" line, and every later
|
||||
request failed with "Failed to start streaming". Head tracking kept working.
|
||||
Clearing it needs the DSP service restarted or the Frame rebooted, so `pulse`
|
||||
never signals the tool. After Ctrl-C or a failure it keeps deleting images
|
||||
until the tool ends by itself (at most the `--seconds` plus a few seconds),
|
||||
and only kills a tool that overruns by 30 s, with a warning that the eye
|
||||
cameras may need a reboot. So an interrupted run can take a while to return.
|
||||
|
||||
**Verified on synthetic data** (unit tests): a 0.3% brightness pulse in a
|
||||
third of the patches, with noise, drift, blinks and eye movement, is
|
||||
recovered within 1.5 BPM at 58, 72 and 115 BPM; noise and blinks alone are
|
||||
not reported as a pulse. **Not yet verified:** whether a real wearer's eye
|
||||
images contain a usable pulse, and how accurate it is. That needs someone
|
||||
wearing the headset and a reference, as below.
|
||||
|
||||
### Comparing with an Apple Watch
|
||||
|
||||
1. On the watch, start a workout (for example **Other**) so it measures heart
|
||||
rate every few seconds rather than occasionally.
|
||||
2. Put the Frame on, sit still and look ahead. Run:
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py pulse --seconds 120 --show \
|
||||
--log /home/steamos/pulse.csv
|
||||
```
|
||||
|
||||
The per-second estimates print at the end. Compare them with what the
|
||||
watch showed.
|
||||
3. End the workout. On the iPhone, open Health → your picture → **Export All
|
||||
Health Data**, and AirDrop `export.zip` to the Mac.
|
||||
4. On the Mac:
|
||||
|
||||
```sh
|
||||
scp frame:pulse.csv . && ssh frame rm pulse.csv
|
||||
python3 scripts/heart-check.py compare pulse.csv ~/Downloads/export.zip
|
||||
```
|
||||
|
||||
This first version analyses after the capture ends, because the method must
|
||||
prove itself before a live panel is worth building. The Apple Watch is a
|
||||
reference, not ground truth: in workouts it is typically within a few BPM of
|
||||
a chest strap when you are still.
|
||||
|
||||
## SlimeVR: feasibility only
|
||||
|
||||
SlimeVR is an independent application stack. Neither of our features installs,
|
||||
launches or depends on it. Users who want it can follow
|
||||
[SlimeVR's setup documentation](https://docs.slimevr.dev/server/index.html).
|
||||
The consented upstream releases tested were
|
||||
[server v21.1.0](https://github.com/SlimeVR/SlimeVR-Server/releases/tag/v21.1.0)
|
||||
and [driver v6.0.0](https://github.com/SlimeVR/SlimeVR-OpenVR-Driver/releases/tag/v6.0.0),
|
||||
under SlimeVR's MIT/Apache-2.0 licensing.
|
||||
|
||||
**Verified layout, read-only:** the Frame's registered runtime is `/opt/steamvr`;
|
||||
its native driver is `drivers/cv/bin/linuxarm64/driver_cv.so`, with a
|
||||
`drivers/cv/driver.vrdrivermanifest`. Frame controller manifests/resources are
|
||||
under `drivers/frame_controller/`. Configuration is under
|
||||
`~/.config/openvr/config/`, not the Steam client's config directory. The
|
||||
SlimeVR release also uses `slimevr/bin/linuxarm64/driver_slimevr.so` plus its
|
||||
manifest. Nothing in those installed SteamVR directories was changed.
|
||||
|
||||
**Inferred:** the matching ABI/layout and standalone factory success make
|
||||
SteamVR integration plausible. They do not prove successful driver `Init`,
|
||||
server/driver IPC, tracking, or calibration. That needs a separate integration
|
||||
check with hardware and an agreed SteamVR restart. No Java executable was on
|
||||
PATH for this check, so an isolated JRE was used. SlimeVR's server opens LAN
|
||||
listeners; our temporary server was stopped and the temporary downloads,
|
||||
configuration and logs were removed. It is not left installed or running.
|
||||
|
||||
## Tests and remaining checks
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
`tests/test_tracking.py` covers HRS packet parsing, contact/staleness, OSC
|
||||
padding/types and a real loopback socket, quaternion signs, opt-in networking,
|
||||
private/exclusive logging and a fake BlueZ object tree. The fake checks service
|
||||
ownership, notification routing, delayed GATT discovery and connection cleanup.
|
||||
It does not pretend to be a physical strap or a real OpenXR runtime.
|
||||
|
||||
Before calling hardware support complete, attach a strap and check BPM against
|
||||
its own display/reference, loss of contact, disconnect/reconnect, Stop, OSC and
|
||||
CSV together. Check avatar eyes while looking up/down/left/right in a supported
|
||||
VRChat session. No third-party tracking app is needed for either test.
|
||||
|
||||
Independent review attempt: `devin -p --model swe-2-max` with the frozen diff,
|
||||
contribution standards and read-only instructions returned no output for ten
|
||||
minutes. It was terminated with exit 143. No completed review or actual model
|
||||
identity was returned; hardware checks and independent review remain follow-up
|
||||
work before making the draft ready.
|
||||
@@ -0,0 +1,135 @@
|
||||
/* Frame Control's OpenXR gaze source. No values on stdout/stderr or disk.
|
||||
* The Python bridge supplies a private pipe with --fd; standalone probes only
|
||||
* report counters. Uses a headless session, never submits frames or takes focus. */
|
||||
#define XR_USE_TIMESPEC
|
||||
#include <time.h>
|
||||
#include <openxr/openxr.h>
|
||||
#include <openxr/openxr_platform.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static volatile sig_atomic_t stopped;
|
||||
static void stop(int sig) { (void)sig; stopped = 1; }
|
||||
#define CHECK(call) do { result = (call); if (XR_FAILED(result)) { \
|
||||
fprintf(stderr, "%s failed (%d)\n", #call, result); goto cleanup; } } while (0)
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int seconds = 10, fd = -1, running = 0, rc = 1;
|
||||
unsigned active = 0, valid = 0, samples = 0;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (!strcmp(argv[i], "--seconds") && i+1 < argc) seconds = atoi(argv[++i]);
|
||||
else if (!strcmp(argv[i], "--fd") && i+1 < argc) fd = atoi(argv[++i]);
|
||||
else { fprintf(stderr, "usage: gaze [--seconds 1..86400] [--fd private-pipe]\n"); return 2; }
|
||||
}
|
||||
if (seconds < 1 || seconds > 86400 || (fd != -1 && fd < 3)) return 2;
|
||||
FILE *out = fd == -1 ? NULL : fdopen(fd, "w");
|
||||
if (fd != -1 && !out) return 2;
|
||||
signal(SIGINT, stop); signal(SIGTERM, stop); signal(SIGHUP, stop); signal(SIGPIPE, SIG_IGN);
|
||||
XrResult result;
|
||||
XrInstance instance = XR_NULL_HANDLE;
|
||||
XrSession session = XR_NULL_HANDLE;
|
||||
XrActionSet set = XR_NULL_HANDLE;
|
||||
XrSpace gaze = XR_NULL_HANDLE, view = XR_NULL_HANDLE;
|
||||
const char *extensions[] = {"XR_EXT_eye_gaze_interaction", "XR_MND_headless", "XR_KHR_convert_timespec_time"};
|
||||
XrInstanceCreateInfo create = {.type = XR_TYPE_INSTANCE_CREATE_INFO};
|
||||
strcpy(create.applicationInfo.applicationName, "Frame Control gaze");
|
||||
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
|
||||
create.enabledExtensionCount = 3; create.enabledExtensionNames = extensions;
|
||||
CHECK(xrCreateInstance(&create, &instance));
|
||||
XrSystemGetInfo get = {.type = XR_TYPE_SYSTEM_GET_INFO, .formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY};
|
||||
XrSystemId system;
|
||||
CHECK(xrGetSystem(instance, &get, &system));
|
||||
XrSystemEyeGazeInteractionPropertiesEXT eye = {.type = XR_TYPE_SYSTEM_EYE_GAZE_INTERACTION_PROPERTIES_EXT};
|
||||
XrSystemProperties props = {.type = XR_TYPE_SYSTEM_PROPERTIES, .next = &eye};
|
||||
CHECK(xrGetSystemProperties(instance, system, &props));
|
||||
printf("supportsEyeGazeInteraction=%u\n", eye.supportsEyeGazeInteraction);
|
||||
if (!eye.supportsEyeGazeInteraction) goto cleanup;
|
||||
XrSessionCreateInfo sc = {.type = XR_TYPE_SESSION_CREATE_INFO, .systemId = system};
|
||||
CHECK(xrCreateSession(instance, &sc, &session));
|
||||
XrActionSetCreateInfo asc = {.type = XR_TYPE_ACTION_SET_CREATE_INFO};
|
||||
strcpy(asc.actionSetName, "gaze"); strcpy(asc.localizedActionSetName, "Gaze");
|
||||
CHECK(xrCreateActionSet(instance, &asc, &set));
|
||||
XrActionCreateInfo ac = {.type = XR_TYPE_ACTION_CREATE_INFO, .actionType = XR_ACTION_TYPE_POSE_INPUT};
|
||||
strcpy(ac.actionName, "gaze_pose"); strcpy(ac.localizedActionName, "Gaze pose");
|
||||
XrAction action;
|
||||
CHECK(xrCreateAction(set, &ac, &action));
|
||||
XrPath profile, input;
|
||||
CHECK(xrStringToPath(instance, "/interaction_profiles/ext/eye_gaze_interaction", &profile));
|
||||
CHECK(xrStringToPath(instance, "/user/eyes_ext/input/gaze_ext/pose", &input));
|
||||
XrActionSuggestedBinding binding = {action, input};
|
||||
XrInteractionProfileSuggestedBinding suggested = {.type = XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING,
|
||||
.interactionProfile = profile, .countSuggestedBindings = 1, .suggestedBindings = &binding};
|
||||
CHECK(xrSuggestInteractionProfileBindings(instance, &suggested));
|
||||
XrSessionActionSetsAttachInfo attach = {.type = XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO, .countActionSets = 1, .actionSets = &set};
|
||||
CHECK(xrAttachSessionActionSets(session, &attach));
|
||||
XrActionSpaceCreateInfo space = {.type = XR_TYPE_ACTION_SPACE_CREATE_INFO, .action = action, .poseInActionSpace.orientation.w = 1};
|
||||
CHECK(xrCreateActionSpace(session, &space, &gaze));
|
||||
XrReferenceSpaceCreateInfo ref = {.type = XR_TYPE_REFERENCE_SPACE_CREATE_INFO, .referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW,
|
||||
.poseInReferenceSpace.orientation.w = 1};
|
||||
CHECK(xrCreateReferenceSpace(session, &ref, &view));
|
||||
PFN_xrConvertTimespecTimeToTimeKHR convert;
|
||||
CHECK(xrGetInstanceProcAddr(instance, "xrConvertTimespecTimeToTimeKHR", (PFN_xrVoidFunction *)&convert));
|
||||
struct timespec start, now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &start);
|
||||
while (!stopped) {
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
if (now.tv_sec - start.tv_sec >= seconds) break;
|
||||
XrEventDataBuffer event = {.type = XR_TYPE_EVENT_DATA_BUFFER};
|
||||
while ((result = xrPollEvent(instance, &event)) == XR_SUCCESS) {
|
||||
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
|
||||
XrSessionState state = ((XrEventDataSessionStateChanged *)&event)->state;
|
||||
printf("sessionState=%d\n", state); fflush(stdout);
|
||||
if (state == XR_SESSION_STATE_READY && !running) {
|
||||
XrSessionBeginInfo begin = {.type = XR_TYPE_SESSION_BEGIN_INFO, .primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO};
|
||||
CHECK(xrBeginSession(session, &begin)); running = 1;
|
||||
} else if (state == XR_SESSION_STATE_STOPPING) {
|
||||
CHECK(xrEndSession(session)); running = 0; stopped = 1;
|
||||
} else if (state == XR_SESSION_STATE_EXITING || state == XR_SESSION_STATE_LOSS_PENDING) stopped = 1;
|
||||
} else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) stopped = 1;
|
||||
event.type = XR_TYPE_EVENT_DATA_BUFFER;
|
||||
}
|
||||
if (XR_FAILED(result)) goto cleanup;
|
||||
if (running && !stopped) {
|
||||
XrActiveActionSet activeSet = {set, XR_NULL_PATH};
|
||||
XrActionsSyncInfo sync = {.type = XR_TYPE_ACTIONS_SYNC_INFO, .countActiveActionSets = 1, .activeActionSets = &activeSet};
|
||||
CHECK(xrSyncActions(session, &sync));
|
||||
if (result != XR_SUCCESS) {
|
||||
struct timespec delay = {.tv_nsec = 33333333};
|
||||
nanosleep(&delay, NULL);
|
||||
continue; /* No stale gaze when the runtime denies focus. */
|
||||
}
|
||||
XrActionStateGetInfo ag = {.type = XR_TYPE_ACTION_STATE_GET_INFO, .action = action};
|
||||
XrActionStatePose pose = {.type = XR_TYPE_ACTION_STATE_POSE};
|
||||
CHECK(xrGetActionStatePose(session, &ag, &pose));
|
||||
samples++;
|
||||
if (pose.isActive) {
|
||||
active++;
|
||||
XrTime time; CHECK(convert(instance, &now, &time));
|
||||
XrSpaceLocation location = {.type = XR_TYPE_SPACE_LOCATION};
|
||||
CHECK(xrLocateSpace(gaze, view, time, &location));
|
||||
XrSpaceLocationFlags needed = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT;
|
||||
if ((location.locationFlags & needed) == needed) {
|
||||
valid++;
|
||||
if (out) {
|
||||
XrQuaternionf q = location.pose.orientation;
|
||||
if (fprintf(out, "%g %g %g %g\n", q.x, q.y, q.z, q.w) < 0 || fflush(out)) goto cleanup;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
struct timespec delay = {.tv_nsec = 33333333}; nanosleep(&delay, NULL);
|
||||
}
|
||||
printf("samples=%u active=%u valid=%u\n", samples, active, valid);
|
||||
rc = valid ? 0 : 3; /* Distinguish a working session from observed gaze. */
|
||||
cleanup:
|
||||
if (view) xrDestroySpace(view);
|
||||
if (gaze) xrDestroySpace(gaze);
|
||||
if (session) xrDestroySession(session);
|
||||
if (set) xrDestroyActionSet(set);
|
||||
if (instance) xrDestroyInstance(instance);
|
||||
if (out) fclose(out);
|
||||
return rc;
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Experimental pulse estimate from the Frame's IR eye-tracking cameras.
|
||||
|
||||
Skin brightens and darkens very slightly with each heartbeat as blood volume
|
||||
changes (photoplethysmography). The eye cameras film the skin around each eye
|
||||
under steady IR light at about 90 frames per second, so that rhythm may be
|
||||
visible in the average brightness of small patches of skin.
|
||||
|
||||
Capture uses SteamVR's own `eyetracking --calib` mode, which writes PNG pairs
|
||||
to /tmp. Each image is reduced to a grid of patch averages the moment it is
|
||||
complete, then deleted; the capture directory is removed on exit. No image
|
||||
leaves the Frame or outlives the run. This is an experiment, not a medical
|
||||
measurement.
|
||||
"""
|
||||
from array import array
|
||||
import bisect
|
||||
import cmath
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ET_DIR = Path("/opt/steamvr/tools/eyetracking")
|
||||
ET_BIN = ET_DIR / "bin/linuxarm64/eyetracking"
|
||||
ET_WEIGHTS = ET_DIR / "resources/et_dsp_20250610_03136.weights"
|
||||
GRID = 16 # 16 × 16 patches of 25 × 25 pixels on the 400 × 400 image
|
||||
RATE = 15.0 # analysis sample rate, Hz; the band of interest ends at 3 Hz
|
||||
LOW, HIGH = 42.0, 180.0 # BPM search band
|
||||
# The Frame's proximity sensor (vcnl4000) reads about 3 with nobody wearing it.
|
||||
# A worn reading has not been measured yet, so the cut-off is provisional.
|
||||
IIO = Path("/sys/bus/iio/devices")
|
||||
WORN_MIN = 20.0
|
||||
WINDOW = 15.0 # seconds per windowed estimate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- capture ---
|
||||
|
||||
def grid_means(pixels, width, height, stride, channels, grid=GRID):
|
||||
"""Average of channel 0 in each of grid × grid equal patches."""
|
||||
bw, bh = width // grid, height // grid
|
||||
sums = [0] * (grid * grid)
|
||||
for y in range(bh * grid):
|
||||
start = y * stride
|
||||
row = pixels[start:start + width * channels:channels]
|
||||
base = (y // bh) * grid
|
||||
for bx in range(grid):
|
||||
sums[base + bx] += sum(row[bx * bw:(bx + 1) * bw])
|
||||
area = bw * bh
|
||||
return [s / area for s in sums]
|
||||
|
||||
|
||||
def load_grid(path):
|
||||
import gi
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
from gi.repository import GdkPixbuf
|
||||
image = GdkPixbuf.Pixbuf.new_from_file(str(path))
|
||||
return grid_means(image.read_pixel_bytes().get_data(), image.get_width(), image.get_height(),
|
||||
image.get_rowstride(), image.get_n_channels())
|
||||
|
||||
|
||||
def reduce_file(loader, path):
|
||||
"""Reduce one image to its patch grid and delete it, whatever happens."""
|
||||
try:
|
||||
return loader(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class Capture:
|
||||
"""Run SteamVR's eye-camera capture and reduce frames as they arrive."""
|
||||
NAME = re.compile(r"^(left|right)_(\d+)\.png$")
|
||||
# Only SteamVR's own capture directories are read and removed.
|
||||
PREFIX = "/tmp/etcalib_"
|
||||
# Printed by the capture tool; its output is only flushed when it exits.
|
||||
WRITING = re.compile(r"Writing capture to: (\S+)")
|
||||
# About five seconds of frames (~65 MB in RAM-backed /tmp). If reduction
|
||||
# falls further behind than this, the capture stops rather than letting
|
||||
# eye images pile up.
|
||||
MAX_BACKLOG = 900
|
||||
|
||||
def __init__(self, seconds, runner=subprocess.Popen, loader=load_grid, workers=3):
|
||||
self.seconds, self.runner, self.loader, self.workers = seconds, runner, loader, workers
|
||||
self.grids = {"left": {}, "right": {}}
|
||||
self.directory = None
|
||||
self.pool = None
|
||||
self.submitted = set()
|
||||
self.futures = {}
|
||||
self.new = set() # every capture directory that appeared during our run
|
||||
self.before = None # capture directories that existed before the run
|
||||
self.log = None
|
||||
|
||||
def candidates(self):
|
||||
parent, stem = os.path.split(self.PREFIX)
|
||||
return {Path(entry.path) for entry in os.scandir(parent)
|
||||
if entry.name.startswith(stem) and entry.is_dir(follow_symlinks=False)}
|
||||
|
||||
def run(self):
|
||||
# The capture tool's output goes to a private temporary file, read for
|
||||
# failure messages. It is block-buffered, so the capture directory is
|
||||
# found by watching for a new one rather than waiting for its name.
|
||||
import tempfile
|
||||
self.before = before = self.candidates()
|
||||
process = None
|
||||
with tempfile.TemporaryFile("w+") as log:
|
||||
self.log = log
|
||||
try:
|
||||
if self.workers:
|
||||
# SteamVR pins its eye tracker to cores 0-1; decoding runs beside it.
|
||||
import concurrent.futures
|
||||
import multiprocessing
|
||||
self.pool = concurrent.futures.ProcessPoolExecutor(
|
||||
self.workers, mp_context=multiprocessing.get_context("fork"))
|
||||
process = self.runner([str(ET_BIN), "-b", "CDSP", "-w", str(ET_WEIGHTS), "--calib", str(self.seconds)],
|
||||
cwd=str(ET_BIN.parent), stdout=log, stderr=subprocess.STDOUT)
|
||||
deadline = time.monotonic() + self.seconds + 30
|
||||
while True:
|
||||
# Checked on every poll: a second capture directory means
|
||||
# we can't tell which images are ours, so stop.
|
||||
self.new |= self.candidates() - before
|
||||
if len(self.new) > 1:
|
||||
raise RuntimeError("another eye-camera capture is running")
|
||||
if not self.directory and self.new:
|
||||
self.directory = next(iter(self.new))
|
||||
finished = process.poll() is not None
|
||||
self.reduce(final=finished)
|
||||
if finished:
|
||||
break
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("eye-camera capture did not finish")
|
||||
time.sleep(0.02)
|
||||
log.seek(0)
|
||||
text = log.read()
|
||||
if process.returncode or not self.directory:
|
||||
reason = "cameras unavailable" if "Failed to" in text else f"exit {process.returncode}"
|
||||
raise RuntimeError(f"eye-camera capture failed ({reason})")
|
||||
named = self.written(log)
|
||||
if named and named != self.directory:
|
||||
raise RuntimeError("the capture wrote somewhere else; not using those images")
|
||||
return self.frames()
|
||||
finally:
|
||||
# Each step runs even if an earlier one failed: eye images must
|
||||
# be removed whatever else went wrong.
|
||||
# Ctrl-C and SIGTERM (raised as KeyboardInterrupt) are held
|
||||
# until the images are gone, then re-raised.
|
||||
interrupted, failed = None, None
|
||||
for step in (lambda: self.finish(process),
|
||||
lambda: self.pool and self.pool.shutdown(wait=True, cancel_futures=True),
|
||||
self.adopt_reported):
|
||||
try:
|
||||
step()
|
||||
except BaseException as error:
|
||||
if isinstance(error, Exception):
|
||||
failed = failed or error
|
||||
else:
|
||||
interrupted = interrupted or error
|
||||
self.log = None
|
||||
self.remove()
|
||||
if interrupted:
|
||||
raise interrupted
|
||||
if failed:
|
||||
raise RuntimeError(f"the eye-camera capture did not stop cleanly: {failed}")
|
||||
|
||||
def finish(self, process):
|
||||
"""Let the capture tool end by itself, deleting its images meanwhile.
|
||||
|
||||
Never signal it: stopping the tool early leaves the headset's eye
|
||||
camera stuck streaming until the DSP service restarts (verified on the
|
||||
Frame with SIGTERM). Its run is bounded by `seconds`, so waiting is
|
||||
short. Only if it overruns by a wide margin is it killed."""
|
||||
if not process:
|
||||
return
|
||||
interrupted = None
|
||||
give_up = time.monotonic() + self.seconds + 30
|
||||
while True:
|
||||
try:
|
||||
if process.poll() is not None:
|
||||
break
|
||||
self.discard()
|
||||
if time.monotonic() > give_up:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise RuntimeError("the eye-camera capture had to be killed; "
|
||||
"the eye cameras may need a reboot to stream again")
|
||||
time.sleep(0.05)
|
||||
except KeyboardInterrupt as error: # keep cleaning up until it ends
|
||||
interrupted = interrupted or error
|
||||
if interrupted:
|
||||
raise interrupted
|
||||
|
||||
def discard(self):
|
||||
"""Delete the eye images written so far, without reading them."""
|
||||
if self.before is not None:
|
||||
self.new |= self.candidates() - self.before
|
||||
for directory in self.new | ({self.directory} if self.directory else set()):
|
||||
if str(directory).startswith(self.PREFIX) and directory.is_dir() and not directory.is_symlink():
|
||||
for entry in os.scandir(directory):
|
||||
if self.NAME.match(entry.name):
|
||||
try:
|
||||
os.unlink(entry.path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def adopt_reported(self):
|
||||
"""The directory the tool reported is ours by its own account."""
|
||||
named = self.written(self.log)
|
||||
if named and str(named).startswith(self.PREFIX):
|
||||
self.new.add(named)
|
||||
|
||||
def written(self, log):
|
||||
"""The directory the capture tool reported, once its output is flushed."""
|
||||
log.seek(0)
|
||||
match = self.WRITING.search(log.read())
|
||||
return Path(match.group(1)) if match else None
|
||||
|
||||
def reduce(self, final=False):
|
||||
"""Reduce and delete every complete image; an image is complete once a
|
||||
later one of the same eye exists, or the capture has ended."""
|
||||
if not self.directory or not self.directory.is_dir():
|
||||
return
|
||||
pending = {"left": [], "right": []}
|
||||
for entry in os.scandir(self.directory):
|
||||
match = self.NAME.match(entry.name)
|
||||
if match:
|
||||
pending[match.group(1)].append((int(match.group(2)), entry.path))
|
||||
if sum(len(files) for files in pending.values()) > self.MAX_BACKLOG:
|
||||
raise RuntimeError("eye-image processing fell behind; capture abandoned")
|
||||
for eye, files in pending.items():
|
||||
files.sort()
|
||||
ready = files if final else files[:-1]
|
||||
for index, path in ready:
|
||||
if path in self.submitted:
|
||||
continue
|
||||
self.submitted.add(path)
|
||||
if self.pool:
|
||||
self.futures[(eye, index)] = self.pool.submit(reduce_file, self.loader, path)
|
||||
else:
|
||||
self.grids[eye][index] = array("f", reduce_file(self.loader, path))
|
||||
for key, future in list(self.futures.items()):
|
||||
if final or future.done():
|
||||
self.grids[key[0]][key[1]] = array("f", future.result())
|
||||
del self.futures[key]
|
||||
|
||||
def frames(self):
|
||||
"""[(monotonic seconds, eye, grid)] joined with the capture metadata."""
|
||||
meta = json.loads((self.directory / "meta.json").read_text())
|
||||
frames = []
|
||||
for pair in meta["frames"]:
|
||||
for eye in ("left", "right"):
|
||||
info = pair.get(eye) or {}
|
||||
match = self.NAME.match(Path(info.get("fname", "")).name)
|
||||
grid = self.grids[eye].get(int(match.group(2))) if match else None
|
||||
if info.get("valid") and grid is not None:
|
||||
frames.append((float(info["tsMono"]), eye, grid))
|
||||
return frames
|
||||
|
||||
def remove(self):
|
||||
"""Remove every capture directory that appeared during the run. Eye
|
||||
images must not outlive it, so a failure to delete is an error."""
|
||||
left = []
|
||||
if self.before is not None:
|
||||
try:
|
||||
self.new |= self.candidates() - self.before # even if interrupted before the first poll
|
||||
except OSError:
|
||||
pass
|
||||
for directory in self.new | ({self.directory} if self.directory else set()):
|
||||
if str(directory).startswith(self.PREFIX) and directory.is_dir() and not directory.is_symlink():
|
||||
try:
|
||||
shutil.rmtree(directory)
|
||||
except OSError:
|
||||
left.append(str(directory))
|
||||
if left:
|
||||
raise RuntimeError("could not delete eye images in " + ", ".join(sorted(left)))
|
||||
|
||||
|
||||
# --------------------------------------------------------------- analysis ---
|
||||
|
||||
def fft(values):
|
||||
"""In-place iterative radix-2 FFT of a list whose length is a power of 2."""
|
||||
n = len(values)
|
||||
a = list(values)
|
||||
j = 0
|
||||
for i in range(1, n):
|
||||
bit = n >> 1
|
||||
while j & bit:
|
||||
j ^= bit
|
||||
bit >>= 1
|
||||
j |= bit
|
||||
if i < j:
|
||||
a[i], a[j] = a[j], a[i]
|
||||
size = 2
|
||||
while size <= n:
|
||||
step = cmath.exp(-2j * math.pi / size)
|
||||
half = size // 2
|
||||
for start in range(0, n, size):
|
||||
w = 1
|
||||
for k in range(start, start + half):
|
||||
t = w * a[k + half]
|
||||
a[k + half] = a[k] - t
|
||||
a[k] += t
|
||||
w *= step
|
||||
size *= 2
|
||||
return a
|
||||
|
||||
|
||||
def resample(times, values, rate=RATE):
|
||||
"""Average samples into 1/rate bins from the first sample; empty bins are
|
||||
filled from the previous bin."""
|
||||
if not times:
|
||||
return []
|
||||
start = times[0]
|
||||
count = int((times[-1] - start) * rate) + 1
|
||||
sums, counts = [0.0] * count, [0] * count
|
||||
for t, v in zip(times, values):
|
||||
i = min(int((t - start) * rate), count - 1)
|
||||
sums[i] += v
|
||||
counts[i] += 1
|
||||
out, last = [], None
|
||||
for s, c in zip(sums, counts):
|
||||
last = s / c if c else last
|
||||
out.append(last)
|
||||
first = next(v for v in out if v is not None)
|
||||
return [first if v is None else v for v in out]
|
||||
|
||||
|
||||
def clean(signal, rate=RATE):
|
||||
"""Relative change with slow drift removed; None if the patch is mostly
|
||||
blinks or eye movement. Spikes (blinks, saccades) become gaps at the
|
||||
local level rather than clipped steps, which would add false rhythm."""
|
||||
mean = sum(signal) / len(signal)
|
||||
x = [v / mean - 1 for v in signal]
|
||||
half = int(rate) # 2-second centred moving median removes drift and blinks
|
||||
trend = []
|
||||
for i in range(len(x)):
|
||||
chunk = sorted(x[max(0, i - half):i + half + 1])
|
||||
trend.append(chunk[len(chunk) // 2])
|
||||
residual = [v - m for v, m in zip(x, trend)]
|
||||
mad = sorted(abs(v) for v in residual)[len(residual) // 2] or 1e-9
|
||||
limit = 4 * 1.4826 * mad
|
||||
spikes = sum(1 for v in residual if abs(v) > limit)
|
||||
if spikes > 0.05 * len(residual):
|
||||
return None
|
||||
return [v if abs(v) <= limit else 0.0 for v in residual]
|
||||
|
||||
|
||||
def spectrum(signal, rate=RATE):
|
||||
"""[(bpm, power)] within the search band, Hann-windowed and zero-padded."""
|
||||
n = len(signal)
|
||||
size = 1
|
||||
while size < max(n * 4, 256):
|
||||
size *= 2
|
||||
window = [0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)) for i in range(n)] if n > 1 else [1.0]
|
||||
padded = [s * w for s, w in zip(signal, window)] + [0.0] * (size - n)
|
||||
result = fft(padded)
|
||||
out = []
|
||||
for k in range(size // 2):
|
||||
bpm = k * rate / size * 60
|
||||
if LOW <= bpm <= HIGH:
|
||||
out.append((bpm, abs(result[k]) ** 2))
|
||||
return out
|
||||
|
||||
|
||||
def peak(spec):
|
||||
"""Peak BPM with parabolic interpolation between spectral bins."""
|
||||
i = max(range(len(spec)), key=lambda k: spec[k][1])
|
||||
if 0 < i < len(spec) - 1:
|
||||
a, b, c = spec[i - 1][1], spec[i][1], spec[i + 1][1]
|
||||
denominator = a - 2 * b + c
|
||||
offset = 0.5 * (a - c) / denominator if denominator else 0.0
|
||||
return spec[i][0] + offset * (spec[1][0] - spec[0][0])
|
||||
return spec[i][0]
|
||||
|
||||
|
||||
def snr(spec, bpm, width=4.0):
|
||||
"""Power near the pulse and its first harmonic against the rest of the band."""
|
||||
near = sum(p for f, p in spec if abs(f - bpm) <= width or abs(f - 2 * bpm) <= width)
|
||||
rest = sum(p for f, p in spec) - near
|
||||
if near <= 0:
|
||||
return 0.0
|
||||
return near / rest if rest > 0 else float("inf")
|
||||
|
||||
|
||||
def normalised(spec):
|
||||
total = sum(p for _, p in spec) or 1.0
|
||||
return [p / total for _, p in spec]
|
||||
|
||||
|
||||
def usable(values):
|
||||
"""Patches that are neither dark nor saturated for the whole recording."""
|
||||
mean = sum(values) / len(values)
|
||||
return 8 <= mean <= 245
|
||||
|
||||
|
||||
def estimate(times, patches, rate=RATE, share=0.2, window=WINDOW):
|
||||
"""Estimate pulse from patch brightness traces.
|
||||
|
||||
times: monotonic seconds per frame. patches: {name: [brightness per frame]}.
|
||||
Selects the share of usable patches with the clearest periodic signal,
|
||||
combines their spectra and reports the overall and windowed estimates.
|
||||
"""
|
||||
cleaned = {}
|
||||
for name, values in patches.items():
|
||||
if usable(values):
|
||||
signal = clean(resample(times, values, rate), rate)
|
||||
if signal is not None and any(signal): # flat patches carry no rhythm
|
||||
cleaned[name] = signal
|
||||
if not cleaned or len(next(iter(cleaned.values()))) < rate * 8:
|
||||
raise ValueError("need at least 8 seconds of usable eye-camera frames")
|
||||
quality = []
|
||||
for name, signal in cleaned.items():
|
||||
spec = spectrum(signal, rate)
|
||||
own = peak(spec)
|
||||
quality.append((snr(spec, own), name, own, spec))
|
||||
quality.sort(reverse=True)
|
||||
chosen = quality[:max(4, int(len(quality) * share))]
|
||||
combined = [sum(values) for values in zip(*(normalised(spec) for _, _, _, spec in chosen))]
|
||||
bins = [f for f, _ in chosen[0][3]]
|
||||
bpm = peak(list(zip(bins, combined)))
|
||||
top = chosen[:8]
|
||||
agree = sum(1 for _, _, own, _ in top if abs(own - bpm) <= 5) / len(top)
|
||||
series = []
|
||||
samples = int(window * rate)
|
||||
length = len(next(iter(cleaned.values())))
|
||||
for end in range(samples, length + 1, int(rate)):
|
||||
specs = [spectrum(cleaned[name][end - samples:end], rate) for _, name, _, _ in chosen]
|
||||
total = [sum(values) for values in zip(*(normalised(s) for s in specs))]
|
||||
window_bins = [f for f, _ in specs[0]]
|
||||
series.append((times[0] + end / rate, peak(list(zip(window_bins, total)))))
|
||||
return {
|
||||
"bpm": bpm,
|
||||
"agreement": agree,
|
||||
"patches": len(chosen),
|
||||
"usable": len(cleaned),
|
||||
"snr": snr(list(zip(bins, combined)), bpm),
|
||||
"series": series,
|
||||
}
|
||||
|
||||
|
||||
def analyse(frames):
|
||||
"""Estimate from Capture.frames(); both eyes' patches are analysed together
|
||||
on a shared time base, each sample taken from that eye's nearest frame."""
|
||||
times = sorted({t for t, _, _ in frames})
|
||||
patches = {}
|
||||
for eye in ("left", "right"):
|
||||
eye_frames = sorted((t, grid) for t, e, grid in frames if e == eye)
|
||||
if not eye_frames:
|
||||
continue
|
||||
eye_times = [t for t, _ in eye_frames]
|
||||
nearest = []
|
||||
for t in times:
|
||||
i = bisect.bisect_left(eye_times, t)
|
||||
if i == len(eye_times) or (i > 0 and t - eye_times[i - 1] <= eye_times[i] - t):
|
||||
i -= 1
|
||||
nearest.append(i)
|
||||
for k in range(len(eye_frames[0][1])):
|
||||
patches[f"{eye}{k}"] = [eye_frames[i][1][k] for i in nearest]
|
||||
return estimate(times, patches)
|
||||
|
||||
|
||||
def proximity(root=None):
|
||||
"""The headset's proximity reading, or None if it can't be read."""
|
||||
try:
|
||||
for device in sorted(Path(root or IIO).glob("iio:device*")):
|
||||
if (device / "name").read_text().strip() == "vcnl4000":
|
||||
return float((device / "in_proximity_raw").read_text())
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def worn(reading):
|
||||
"""False only when the sensor says the headset is not on a face."""
|
||||
return reading is None or reading >= WORN_MIN
|
||||
|
||||
|
||||
def reliable(result):
|
||||
"""Whether the estimate is clear enough to show as a reading. Thresholds
|
||||
are provisional until checked against a reference on a real wearer."""
|
||||
return result["agreement"] >= 0.75 and result["snr"] >= 0.5
|
||||
@@ -0,0 +1,438 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame-local tracking tools. No network destination or data log by default."""
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
class TrackingError(RuntimeError):
|
||||
"""A safe, actionable status message containing no sensor data."""
|
||||
|
||||
|
||||
HRS = "0000180d-0000-1000-8000-00805f9b34fb"
|
||||
MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb"
|
||||
DEVICE = "org.bluez.Device1"
|
||||
SERVICE = "org.bluez.GattService1"
|
||||
CHARACTERISTIC = "org.bluez.GattCharacteristic1"
|
||||
|
||||
|
||||
def heart_rate(data):
|
||||
"""Validate the Bluetooth HRS measurement, returning BPM/contact only.
|
||||
|
||||
Energy and RR intervals are checked for length but never retained.
|
||||
None means contact is supported and the strap reports no skin contact.
|
||||
"""
|
||||
data = bytes(data)
|
||||
if len(data) < 2 or data[0] & 0xe0:
|
||||
raise ValueError("invalid HRS measurement")
|
||||
flags = data[0]
|
||||
size = 2 if flags & 1 else 1
|
||||
end = 1 + size + (2 if flags & 8 else 0)
|
||||
if len(data) < end:
|
||||
raise ValueError("truncated HRS measurement")
|
||||
extra = len(data) - end
|
||||
if (flags & 16 and (extra < 2 or extra % 2)) or (not flags & 16 and extra):
|
||||
raise ValueError("invalid HRS optional fields")
|
||||
if flags & 4 and not flags & 2:
|
||||
return None
|
||||
bpm = int.from_bytes(data[1:1 + size], "little")
|
||||
return bpm if bpm else None
|
||||
|
||||
|
||||
def gaze_angles(quaternion):
|
||||
"""OpenXR head-relative -Z forward → VRChat degrees, down/right positive."""
|
||||
if len(quaternion) != 4 or not all(math.isfinite(v) for v in quaternion):
|
||||
raise ValueError("invalid gaze orientation")
|
||||
norm = math.sqrt(sum(v * v for v in quaternion))
|
||||
if not 0.9 < norm < 1.1:
|
||||
raise ValueError("invalid gaze orientation")
|
||||
x, y, z, w = (v / norm for v in quaternion)
|
||||
# Rotate OpenXR's forward vector (0, 0, -1) into VIEW space.
|
||||
dx, dy, dz = -2 * (x*z + w*y), 2 * (w*x - y*z), 2 * (x*x + y*y) - 1
|
||||
return math.degrees(math.atan2(-dy, math.hypot(dx, dz))), math.degrees(math.atan2(dx, -dz))
|
||||
|
||||
|
||||
def osc_message(address, values):
|
||||
if not address.startswith("/") or any(c.isspace() or c in '\0#*,?[]{}' for c in address):
|
||||
raise ValueError("OSC address must be a literal path")
|
||||
def string(value):
|
||||
encoded = value.encode("utf-8") + b"\0"
|
||||
return encoded + b"\0" * (-len(encoded) % 4)
|
||||
tags, payload = ",", b""
|
||||
for value in values:
|
||||
if type(value) is int:
|
||||
tags += "i"
|
||||
payload += struct.pack(">i", value)
|
||||
else:
|
||||
if not math.isfinite(value):
|
||||
raise ValueError("OSC value must be finite")
|
||||
tags += "f"
|
||||
payload += struct.pack(">f", value)
|
||||
return string(address) + string(tags) + payload
|
||||
|
||||
|
||||
class Osc:
|
||||
def __init__(self, endpoint=None):
|
||||
self.sock = None
|
||||
self.target = None
|
||||
if endpoint:
|
||||
import ipaddress
|
||||
address = ipaddress.ip_address(endpoint[0])
|
||||
port = int(endpoint[1])
|
||||
if address.is_unspecified or address.is_multicast or not 1 <= port <= 65535:
|
||||
raise ValueError("OSC needs a unicast IP address and port 1..65535")
|
||||
self.sock = socket.socket(socket.AF_INET6 if address.version == 6 else socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.target = (str(address), port)
|
||||
|
||||
def send(self, address, values):
|
||||
if self.sock:
|
||||
self.sock.sendto(osc_message(address, values), self.target)
|
||||
|
||||
def close(self):
|
||||
if self.sock:
|
||||
self.sock.close()
|
||||
|
||||
|
||||
class HeartSession:
|
||||
def __init__(self, osc, address, log=None, clock=time.monotonic):
|
||||
self.osc, self.address, self.clock = osc, address, clock
|
||||
self.bpm, self.updated = None, None
|
||||
self.log = None
|
||||
if log:
|
||||
# Exclusive creation refuses existing files and symlinks; mode is
|
||||
# private even with a permissive process umask.
|
||||
fd = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
self.log = os.fdopen(fd, "w")
|
||||
self.log.write("unix_seconds,bpm\n")
|
||||
|
||||
def notification(self, data):
|
||||
self.bpm = heart_rate(data)
|
||||
self.updated = self.clock()
|
||||
if self.bpm is not None:
|
||||
self.osc.send(self.address, [self.bpm])
|
||||
if self.log:
|
||||
self.log.write(f"{time.time():.3f},{self.bpm}\n")
|
||||
self.log.flush()
|
||||
|
||||
def current(self):
|
||||
if self.updated is None or self.clock() - self.updated > 5:
|
||||
return None
|
||||
return self.bpm
|
||||
|
||||
def close(self):
|
||||
if self.log:
|
||||
self.log.close()
|
||||
|
||||
|
||||
class BluezHeart:
|
||||
"""One explicitly selected, already discovered strap; no ambient scan."""
|
||||
def __init__(self, bus, interface, address, on_value):
|
||||
self.bus, self.interface, self.on_value = bus, interface, on_value
|
||||
self.device = self.characteristic = None
|
||||
self.connected_here = False
|
||||
self.notifying = False
|
||||
self.match = None
|
||||
objects = self.objects()
|
||||
matches = [path for path, interfaces in objects.items()
|
||||
if str(interfaces.get(DEVICE, {}).get("Address", "")).upper() == address.upper()]
|
||||
if len(matches) != 1:
|
||||
raise TrackingError("Strap not found uniquely in BlueZ; pair/discover it in SteamOS Bluetooth settings first")
|
||||
self.device = matches[0]
|
||||
self.match = bus.add_signal_receiver(self.changed, signal_name="PropertiesChanged",
|
||||
dbus_interface="org.freedesktop.DBus.Properties",
|
||||
bus_name="org.bluez", path_keyword="path")
|
||||
try:
|
||||
if not objects[self.device][DEVICE].get("Connected"):
|
||||
self.call(self.device, DEVICE).Connect(timeout=20)
|
||||
self.connected_here = True
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def objects(self):
|
||||
return self.call("/", "org.freedesktop.DBus.ObjectManager").GetManagedObjects()
|
||||
|
||||
def call(self, path, kind):
|
||||
return self.interface(self.bus.get_object("org.bluez", path), kind)
|
||||
|
||||
def subscribe(self):
|
||||
objects = self.objects()
|
||||
if not objects.get(self.device, {}).get(DEVICE, {}).get("ServicesResolved"):
|
||||
return False
|
||||
services = {p for p, obj in objects.items() if str(obj.get(SERVICE, {}).get("UUID", "")).lower() == HRS
|
||||
and obj[SERVICE].get("Device") == self.device}
|
||||
for path, obj in objects.items():
|
||||
props = obj.get(CHARACTERISTIC, {})
|
||||
if props.get("Service") in services and str(props.get("UUID", "")).lower() == MEASUREMENT:
|
||||
if "notify" not in props.get("Flags", []):
|
||||
raise TrackingError("Heart-rate characteristic does not support notifications")
|
||||
self.characteristic = path
|
||||
self.call(path, CHARACTERISTIC).StartNotify()
|
||||
self.notifying = True
|
||||
return True
|
||||
raise TrackingError("Selected device has no standard Heart Rate Service measurement")
|
||||
|
||||
def changed(self, kind, changes, invalidated, path=None):
|
||||
if kind == CHARACTERISTIC and path == self.characteristic and "Value" in changes:
|
||||
self.on_value(changes["Value"])
|
||||
elif kind == DEVICE and path == self.device and "Connected" in changes and not changes["Connected"]:
|
||||
self.on_value(None)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
if self.notifying:
|
||||
self.call(self.characteristic, CHARACTERISTIC).StopNotify()
|
||||
finally:
|
||||
if self.match:
|
||||
self.match.remove()
|
||||
if self.connected_here:
|
||||
self.call(self.device, DEVICE).Disconnect()
|
||||
|
||||
|
||||
def run_gaze(args, osc):
|
||||
binary = Path(__file__).with_name("gaze")
|
||||
command = [str(binary), "--seconds", str(args.seconds)]
|
||||
if not args.osc:
|
||||
return subprocess.call(command)
|
||||
read_fd, write_fd = os.pipe()
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(command + ["--fd", str(write_fd)], pass_fds=(write_fd,))
|
||||
os.close(write_fd)
|
||||
write_fd = None
|
||||
with os.fdopen(read_fd) as source:
|
||||
read_fd = None
|
||||
for line in source:
|
||||
try:
|
||||
angles = gaze_angles([float(v) for v in line.split()])
|
||||
except ValueError:
|
||||
continue
|
||||
osc.send("/tracking/eye/CenterPitchYaw", angles)
|
||||
return process.wait()
|
||||
finally:
|
||||
if read_fd is not None:
|
||||
os.close(read_fd)
|
||||
if write_fd is not None:
|
||||
os.close(write_fd)
|
||||
if process and process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
class HeartPanel:
|
||||
"""Our GTK panel, using the Frame's existing GTK4/GI platform libraries."""
|
||||
def __init__(self):
|
||||
os.environ["GDK_BACKEND"] = "x11"
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("GdkX11", "4.0")
|
||||
from gi.repository import Gtk, Gdk, GdkX11, GLib
|
||||
Gtk.init()
|
||||
self.running = True
|
||||
self.window = Gtk.Window(title="Frame Control · Heart rate")
|
||||
self.window.set_default_size(480, 320)
|
||||
self.window.connect("close-request", self.stop)
|
||||
Gtk.Settings.get_default().set_property("gtk-application-prefer-dark-theme", True)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
||||
box.set_valign(Gtk.Align.CENTER)
|
||||
box.set_halign(Gtk.Align.CENTER)
|
||||
box.set_size_request(440, -1)
|
||||
for side in ("top", "bottom", "start", "end"):
|
||||
getattr(box, "set_margin_" + side)(24)
|
||||
self.window.set_child(box)
|
||||
title = Gtk.Label(label="Heart rate")
|
||||
title.add_css_class("title-2")
|
||||
box.append(title)
|
||||
self.reading = Gtk.Label(label="—")
|
||||
self.reading.add_css_class("reading")
|
||||
box.append(self.reading)
|
||||
self.status = Gtk.Label(label="Waiting for strap")
|
||||
box.append(self.status)
|
||||
button = Gtk.Button(label="Stop")
|
||||
button.connect("clicked", self.stop)
|
||||
box.append(button)
|
||||
css = Gtk.CssProvider()
|
||||
css.load_from_data(b".reading { font-size: 144px; font-weight: 700; }")
|
||||
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||
self.window.present()
|
||||
context = GLib.MainContext.default()
|
||||
while context.pending():
|
||||
context.iteration(False)
|
||||
try:
|
||||
xid = GdkX11.X11Surface.get_xid(self.window.get_surface())
|
||||
subprocess.run(["xprop", "-id", str(xid), "-f", "STEAM_GAME", "32c", "-set", "STEAM_GAME", "2000000027"],
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
except Exception:
|
||||
self.window.destroy()
|
||||
raise
|
||||
|
||||
def stop(self, *args):
|
||||
self.running = False
|
||||
return True
|
||||
|
||||
def update(self, bpm):
|
||||
self.reading.set_label(str(bpm) if bpm is not None else "—")
|
||||
self.status.set_label("beats per minute" if bpm is not None else "Waiting for strap")
|
||||
|
||||
def close(self):
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
def run_heart(args, osc):
|
||||
import dbus
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
session = HeartSession(osc, args.address, args.log)
|
||||
reader, root = None, None
|
||||
failure = []
|
||||
def value(data):
|
||||
if data is None:
|
||||
session.bpm = None
|
||||
failure.append("Strap disconnected; reconnect and start again")
|
||||
return
|
||||
try:
|
||||
session.notification(data)
|
||||
except ValueError:
|
||||
session.bpm = None
|
||||
except OSError:
|
||||
failure.append("OSC or session log write failed")
|
||||
try:
|
||||
reader = BluezHeart(dbus.SystemBus(), dbus.Interface, args.device, value)
|
||||
context = GLib.MainContext.default()
|
||||
deadline = time.monotonic() + 20
|
||||
while not reader.subscribe():
|
||||
if time.monotonic() > deadline:
|
||||
raise TrackingError("Timed out waiting for the strap's GATT services")
|
||||
while context.pending():
|
||||
context.iteration(False)
|
||||
time.sleep(0.1)
|
||||
end = time.monotonic() + args.seconds
|
||||
print("Heart-rate notifications started; readings stay local unless OSC or a log was selected.")
|
||||
if args.panel:
|
||||
root = HeartPanel()
|
||||
running = lambda: root.running if root else True
|
||||
while running() and time.monotonic() < end and not failure:
|
||||
while context.pending():
|
||||
context.iteration(False)
|
||||
if root:
|
||||
root.update(session.current())
|
||||
time.sleep(0.05)
|
||||
if failure:
|
||||
raise TrackingError(failure[0])
|
||||
return 0
|
||||
finally:
|
||||
if root:
|
||||
root.close()
|
||||
try:
|
||||
if reader:
|
||||
reader.close()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def run_pulse(args, osc):
|
||||
"""Experimental: estimate pulse from the IR eye cameras (see pulse.py)."""
|
||||
import pulse
|
||||
if not pulse.ET_BIN.exists():
|
||||
raise TrackingError("SteamVR's eye-tracking tool is not installed on this Frame")
|
||||
print(f"Capturing {args.seconds} s from the eye cameras. Wear the headset and keep still.", flush=True)
|
||||
readings = [pulse.proximity()]
|
||||
try:
|
||||
frames = pulse.Capture(args.seconds).run()
|
||||
readings.append(pulse.proximity())
|
||||
except RuntimeError as error: # capture status only; no image data
|
||||
raise TrackingError(str(error))
|
||||
print(f"Captured {len(frames)} eye frames; images already deleted. Analysing...", flush=True)
|
||||
try:
|
||||
result = pulse.analyse(frames)
|
||||
except ValueError as error:
|
||||
raise TrackingError(str(error))
|
||||
on_face = all(pulse.worn(reading) for reading in readings)
|
||||
if not on_face:
|
||||
# Unworn, the cameras still show a steady periodic artifact that looks
|
||||
# like a pulse (verified on the Frame), so it is never called clear.
|
||||
print("The proximity sensor says the headset is not being worn; no pulse can be read.")
|
||||
clear = on_face and pulse.reliable(result)
|
||||
offset = time.time() - time.monotonic() # the capture's timestamps are CLOCK_MONOTONIC
|
||||
if args.log:
|
||||
fd = os.open(args.log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "w") as log:
|
||||
log.write("unix_seconds,bpm\n")
|
||||
for when, bpm in result["series"]:
|
||||
log.write(f"{when + offset:.3f},{bpm:.1f}\n")
|
||||
if clear:
|
||||
osc.send(args.address, [round(result["bpm"])])
|
||||
if args.show:
|
||||
print(f"Estimate: {result['bpm']:.1f} BPM ({'clear' if clear else 'NOT clear'}; "
|
||||
f"patch agreement {result['agreement']:.0%}, signal/noise {result['snr']:.2f}, "
|
||||
f"{result['patches']} of {result['usable']} usable patches)")
|
||||
print("Per-second estimates (15 s windows): "
|
||||
+ " ".join(str(round(bpm)) for _, bpm in result["series"]))
|
||||
else:
|
||||
print("A clear pulse was found." if clear else "No clear pulse was found.")
|
||||
return 0 if clear else 3
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
gaze = commands.add_parser("gaze", help="headless OpenXR; prints counters only without --osc")
|
||||
heart = commands.add_parser("heart", help="standard BLE HRS from an explicitly selected strap")
|
||||
pulse = commands.add_parser("pulse", help="experimental pulse estimate from the IR eye cameras")
|
||||
for command in (gaze, heart, pulse):
|
||||
command.add_argument("--osc", nargs=2, metavar=("IP", "PORT"), help="explicit UDP destination; no default")
|
||||
for command in (gaze, heart):
|
||||
command.add_argument("--seconds", type=int, default=10, help="bounded run, 1..86400 seconds (default: 10)")
|
||||
pulse.add_argument("--seconds", type=int, default=60, help="capture length, 20..300 seconds (default: 60)")
|
||||
heart.add_argument("--device", required=True, help="strap Bluetooth address already discovered by BlueZ")
|
||||
heart.add_argument("--panel", action="store_true", help="show our panel on gamescope DISPLAY=:0")
|
||||
for command in (heart, pulse):
|
||||
command.add_argument("--address", default="/avatar/parameters/HeartRate", help="integer BPM OSC parameter")
|
||||
command.add_argument("--log", help="new private CSV file; disabled by default")
|
||||
pulse.add_argument("--show", action="store_true", help="print the estimate and per-second series")
|
||||
args = parser.parse_args()
|
||||
if not 1 <= args.seconds <= 86400:
|
||||
parser.error("--seconds must be 1..86400")
|
||||
if args.command == "pulse" and not 20 <= args.seconds <= 300:
|
||||
parser.error("pulse --seconds must be 20..300")
|
||||
def interrupted(signum, frame):
|
||||
raise KeyboardInterrupt
|
||||
signal.signal(signal.SIGTERM, interrupted)
|
||||
if hasattr(signal, "SIGHUP"):
|
||||
signal.signal(signal.SIGHUP, interrupted)
|
||||
osc = None
|
||||
try:
|
||||
if args.command in ("heart", "pulse"):
|
||||
osc_message(args.address, [0])
|
||||
if getattr(args, "panel", False):
|
||||
os.environ["DISPLAY"] = ":0"
|
||||
osc = Osc(args.osc)
|
||||
run = {"gaze": run_gaze, "heart": run_heart, "pulse": run_pulse}[args.command]
|
||||
return run(args, osc)
|
||||
except TrackingError as error:
|
||||
print(str(error))
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
except Exception as error:
|
||||
# Never dump notifications, gaze, BLE addresses or exception payloads.
|
||||
print(f"Tracking stopped ({type(error).__name__}). Check the device, runtime and selected output.")
|
||||
return 1
|
||||
finally:
|
||||
if osc:
|
||||
osc.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+2
-1
@@ -23,7 +23,8 @@ started=$SECONDS
|
||||
|
||||
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
|
||||
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
|
||||
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
|
||||
# That second build also rides out a flaky package mirror: only its failure is fatal.
|
||||
build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 }
|
||||
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
|
||||
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check Frame Control's heart-rate readings against a reference, on your computer.
|
||||
|
||||
python3 scripts/heart-check.py listen --port 9000 --out ours.csv
|
||||
python3 scripts/heart-check.py compare ours.csv reference.csv
|
||||
python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip
|
||||
|
||||
`listen` receives our integer-BPM OSC messages (send them here with
|
||||
`tracking-on-frame.py heart --osc <this computer's IP> 9000`) and shows each
|
||||
reading live, so you can watch it next to a reference such as your Apple
|
||||
Watch. `--out` also records `unix_seconds,bpm` to a new private file.
|
||||
|
||||
`compare` lines up two recordings by time and reports how far apart they are.
|
||||
The reference can be:
|
||||
- a CSV whose first column is a time (unix seconds or ISO 8601) and whose
|
||||
second is BPM, such as our own log, `listen --out`, or the test strap's
|
||||
output (a third `flags` column marks skin-contact loss as "no reading");
|
||||
- an Apple Health export (`export.zip` or `export.xml`, from Health → your
|
||||
profile → Export All Health Data). Only heart-rate records within the
|
||||
recording's time range are read.
|
||||
|
||||
Everything stays on this computer. Nothing is uploaded.
|
||||
"""
|
||||
import argparse
|
||||
import bisect
|
||||
import csv
|
||||
from datetime import datetime
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from xml.etree import ElementTree
|
||||
|
||||
ADDRESS = "/avatar/parameters/HeartRate"
|
||||
|
||||
|
||||
def parse_osc(packet):
|
||||
"""Return (address, values) for a single OSC message with i/f arguments."""
|
||||
def string(offset):
|
||||
end = packet.index(b"\0", offset)
|
||||
return packet[offset:end].decode("utf-8"), (end + 4) & ~3
|
||||
address, offset = string(0)
|
||||
tags, offset = string(offset)
|
||||
if not tags.startswith(","):
|
||||
raise ValueError("not an OSC message")
|
||||
values = []
|
||||
for tag in tags[1:]:
|
||||
if tag not in "if" or offset + 4 > len(packet):
|
||||
raise ValueError("unsupported OSC argument")
|
||||
values.append(struct.unpack(">i" if tag == "i" else ">f", packet[offset:offset + 4])[0])
|
||||
offset += 4
|
||||
return address, values
|
||||
|
||||
|
||||
def listen(port, address, out, seconds, clock=time.time, stream=sys.stdout):
|
||||
log = None
|
||||
if out:
|
||||
log = os.fdopen(os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w")
|
||||
log.write("unix_seconds,bpm\n")
|
||||
received = 0
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.bind(("0.0.0.0", port))
|
||||
sock.settimeout(0.5)
|
||||
print(f"Listening for {address} on UDP port {port}. Ctrl-C stops.", file=stream, flush=True)
|
||||
end = clock() + seconds if seconds else None
|
||||
try:
|
||||
while end is None or clock() < end:
|
||||
try:
|
||||
packet = sock.recv(1024)
|
||||
except socket.timeout:
|
||||
continue
|
||||
try:
|
||||
path, values = parse_osc(packet)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
continue
|
||||
if path != address or len(values) != 1:
|
||||
continue
|
||||
now = clock()
|
||||
bpm = int(values[0])
|
||||
received += 1
|
||||
print(f"{time.strftime('%H:%M:%S', time.localtime(now))} {bpm:3d} bpm", file=stream, flush=True)
|
||||
if log:
|
||||
log.write(f"{now:.3f},{bpm}\n")
|
||||
log.flush()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
if log:
|
||||
log.close()
|
||||
return received
|
||||
|
||||
|
||||
def parse_time(text):
|
||||
text = text.strip()
|
||||
if re.fullmatch(r"\d+(\.\d+)?", text):
|
||||
return float(text)
|
||||
# Apple Health uses "2026-09-29 09:12:03 +1000".
|
||||
text = re.sub(r" ([+-]\d{2}):?(\d{2})$", r"\1\2", text).replace("Z", "+0000")
|
||||
for pattern in ("%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
|
||||
try:
|
||||
return datetime.strptime(text, pattern).timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(f"unrecognised time {text!r}")
|
||||
|
||||
|
||||
def read_csv(path):
|
||||
"""[(time, bpm or None)], sorted. A header row is skipped."""
|
||||
samples = []
|
||||
with open(path, newline="") as source:
|
||||
for row in csv.reader(source):
|
||||
if len(row) < 2:
|
||||
continue
|
||||
try:
|
||||
when, bpm = parse_time(row[0]), int(float(row[1]))
|
||||
except (ValueError, OverflowError):
|
||||
continue # header or unparseable line
|
||||
try:
|
||||
flags = int(float(row[2])) if len(row) > 2 else None
|
||||
except (ValueError, OverflowError):
|
||||
flags = None # an unrecognised flag leaves the reading as it is
|
||||
if flags is not None and flags & 4 and not flags & 2:
|
||||
bpm = None # contact supported and not detected: no reading
|
||||
samples.append((when, bpm))
|
||||
return sorted(samples, key=lambda s: s[0])
|
||||
|
||||
|
||||
def read_health(path, start, end):
|
||||
"""Heart-rate records from an Apple Health export between start and end."""
|
||||
samples = []
|
||||
def scan(source):
|
||||
for _, element in ElementTree.iterparse(source):
|
||||
if element.tag == "Record" and element.get("type") == "HKQuantityTypeIdentifierHeartRate":
|
||||
try:
|
||||
when = parse_time(element.get("startDate") or "")
|
||||
value = round(float(element.get("value") or ""))
|
||||
except (ValueError, OverflowError):
|
||||
continue
|
||||
if start <= when <= end:
|
||||
samples.append((when, value))
|
||||
element.clear()
|
||||
if zipfile.is_zipfile(path):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = [n for n in archive.namelist() if n.endswith("/export.xml") or n == "export.xml"]
|
||||
if not names:
|
||||
raise ValueError("no export.xml in that archive; use Health's Export All Health Data")
|
||||
with archive.open(names[0]) as source:
|
||||
scan(source)
|
||||
else:
|
||||
with open(path, "rb") as source:
|
||||
scan(source)
|
||||
return sorted(samples)
|
||||
|
||||
|
||||
def read_any(path, start=None, end=None):
|
||||
path = str(path)
|
||||
if path.endswith((".zip", ".xml")):
|
||||
return read_health(path, start, end)
|
||||
return read_csv(path)
|
||||
|
||||
|
||||
def value_at(samples, when, hold, times=None):
|
||||
"""Our reading at a moment: the latest sample no older than `hold` seconds.
|
||||
`times` is the samples' time column, if the caller has already built it."""
|
||||
times = times if times is not None else [s[0] for s in samples]
|
||||
i = bisect.bisect_right(times, when) - 1
|
||||
if i < 0 or when - times[i] > hold:
|
||||
return None
|
||||
return samples[i][1]
|
||||
|
||||
|
||||
def compare(ours, reference, max_lag=10.0, hold=5.0):
|
||||
"""Compare our readings with the reference at each reference moment.
|
||||
|
||||
`lag` is the delay added to reference times before looking up ours (our
|
||||
readings arrive after the sensor's). The best lag within ±max_lag is used.
|
||||
"""
|
||||
real = [(t, b) for t, b in reference if b is not None]
|
||||
if not real or not any(b is not None for _, b in ours):
|
||||
raise ValueError("both recordings need at least one reading")
|
||||
best = None
|
||||
ours_times = [t for t, _ in ours]
|
||||
steps = int(max_lag * 4)
|
||||
for step in range(-steps, steps + 1):
|
||||
lag = step / 4
|
||||
pairs = [(value_at(ours, t + lag, hold, ours_times), b) for t, b in real]
|
||||
pairs = [(o, r) for o, r in pairs if o is not None]
|
||||
if not pairs:
|
||||
continue
|
||||
error = sum(abs(o - r) for o, r in pairs) / len(pairs)
|
||||
key = (-len(pairs), error, abs(lag))
|
||||
if best is None or key < best[0]:
|
||||
best = (key, lag, pairs)
|
||||
if best is None:
|
||||
raise ValueError("the recordings do not overlap in time")
|
||||
_, lag, pairs = best
|
||||
differences = [o - r for o, r in pairs]
|
||||
# While the reference says "no reading" (lost skin contact), we must not
|
||||
# produce new readings. Count ours that arrive during such a stretch.
|
||||
gaps = [t for t, b in reference if b is None]
|
||||
reference_times = [t for t, _ in reference]
|
||||
shown_in_gaps = 0
|
||||
for t, bpm in ours:
|
||||
i = bisect.bisect_right(reference_times, t - lag) - 1
|
||||
if bpm is not None and i >= 0 and reference[i][1] is None:
|
||||
shown_in_gaps += 1
|
||||
return {
|
||||
"reference_readings": len(real),
|
||||
"matched": len(pairs),
|
||||
"lag_seconds": lag,
|
||||
"mean_abs_error": sum(abs(d) for d in differences) / len(differences),
|
||||
"bias": sum(differences) / len(differences),
|
||||
"max_abs_error": max(abs(d) for d in differences),
|
||||
"within_5": sum(1 for d in differences if abs(d) <= 5) / len(differences),
|
||||
"exact": sum(1 for d in differences if d == 0) / len(differences),
|
||||
"no_contact_moments": len(gaps),
|
||||
"shown_during_no_contact": shown_in_gaps,
|
||||
}
|
||||
|
||||
|
||||
def report(result, tolerance, stream=sys.stdout):
|
||||
print(f"Reference readings: {result['reference_readings']}, matched: {result['matched']}", file=stream)
|
||||
print(f"Best alignment: ours {result['lag_seconds']:+.2f} s after the reference", file=stream)
|
||||
print(f"Mean absolute difference: {result['mean_abs_error']:.2f} BPM "
|
||||
f"(bias {result['bias']:+.2f}, worst {result['max_abs_error']})", file=stream)
|
||||
print(f"Within ±5 BPM: {result['within_5']:.0%}; identical: {result['exact']:.0%}", file=stream)
|
||||
if result["no_contact_moments"]:
|
||||
print(f"No-contact moments: {result['no_contact_moments']}, where we showed a stale "
|
||||
f"reading: {result['shown_during_no_contact']}", file=stream)
|
||||
coverage = result["matched"] / result["reference_readings"]
|
||||
passed = (result["mean_abs_error"] <= tolerance and coverage >= 0.8
|
||||
and not result["shown_during_no_contact"])
|
||||
print(("PASS" if passed else "FAIL") + f" (mean difference ≤ {tolerance} BPM, ≥80% of reference "
|
||||
f"readings matched, nothing shown without contact)", file=stream)
|
||||
return passed
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
heard = commands.add_parser("listen", help="show and optionally record our OSC readings live")
|
||||
heard.add_argument("--port", type=int, default=9000)
|
||||
heard.add_argument("--address", default=ADDRESS)
|
||||
heard.add_argument("--out", help="new private CSV file")
|
||||
heard.add_argument("--seconds", type=float, default=0, help="stop after this long (default: until Ctrl-C)")
|
||||
check = commands.add_parser("compare", help="compare our recording with a reference")
|
||||
check.add_argument("ours", type=Path)
|
||||
check.add_argument("reference", type=Path)
|
||||
check.add_argument("--tolerance", type=float, default=5.0, help="allowed mean difference in BPM (default 5)")
|
||||
check.add_argument("--max-lag", type=float, default=10.0, help="largest time offset to search, seconds")
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "listen":
|
||||
count = listen(args.port, args.address, args.out, args.seconds)
|
||||
print(f"Received {count} readings.")
|
||||
return 0 if count else 3
|
||||
try:
|
||||
ours = read_csv(args.ours)
|
||||
if not ours:
|
||||
raise ValueError("our recording has no readings")
|
||||
reference = read_any(args.reference, ours[0][0] - 60, ours[-1][0] + 60)
|
||||
result = compare(ours, reference, args.max_lag)
|
||||
except (ValueError, OSError, csv.Error, ElementTree.ParseError, zipfile.BadZipFile) as error:
|
||||
print(f"Could not compare: {error}")
|
||||
return 1
|
||||
return 0 if report(result, args.tolerance) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
// A synthetic Bluetooth heart-rate strap for testing, run on the Mac.
|
||||
//
|
||||
// swiftc -O scripts/heart-test-strap.swift -o /tmp/heart-test-strap
|
||||
// /tmp/heart-test-strap [seconds] # default 60
|
||||
//
|
||||
// Advertises the standard Heart Rate Service (180d) as "FC Test Strap" and,
|
||||
// while a central is subscribed, sends one Heart Rate Measurement (2a37) per
|
||||
// second from a fixed, known sequence. Each sent value is printed to stdout as
|
||||
// `unix_seconds,bpm,flags` so scripts/heart-check.py can compare what the
|
||||
// Frame received with what was sent. Every value is synthetic; this is not a
|
||||
// sensor. macOS asks for Bluetooth permission the first time it runs.
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
let hrs = CBUUID(string: "180D")
|
||||
let measurement = CBUUID(string: "2A37")
|
||||
|
||||
/// The known sequence: an 8-bit ramp, 16-bit encodings, a skin-contact loss
|
||||
/// (which must show as no reading) and a recovery.
|
||||
func packet(_ second: Int) -> (bpm: Int, flags: UInt8) {
|
||||
switch second % 60 {
|
||||
case 0..<30: return (60 + second % 60 * 4, 0x06) // 60…176, contact detected
|
||||
case 30..<40: return (180 - (second % 60 - 30) * 3, 0x07) // 16-bit value
|
||||
case 40..<45: return (150, 0x04) // contact lost
|
||||
default: return (72 + (second % 60 - 45), 0x06)
|
||||
}
|
||||
}
|
||||
|
||||
final class Strap: NSObject, CBPeripheralManagerDelegate {
|
||||
let seconds: Int
|
||||
var manager: CBPeripheralManager!
|
||||
var characteristic: CBMutableCharacteristic!
|
||||
var subscribed = false
|
||||
var sent = 0
|
||||
|
||||
init(seconds: Int) {
|
||||
self.seconds = seconds
|
||||
super.init()
|
||||
manager = CBPeripheralManager(delegate: self, queue: nil)
|
||||
}
|
||||
|
||||
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
|
||||
guard peripheral.state == .poweredOn else {
|
||||
FileHandle.standardError.write("Bluetooth state \(peripheral.state.rawValue)\n".data(using: .utf8)!)
|
||||
if peripheral.state == .unauthorized || peripheral.state == .unsupported || peripheral.state == .poweredOff {
|
||||
FileHandle.standardError.write("Bluetooth unavailable (state \(peripheral.state.rawValue))\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
characteristic = CBMutableCharacteristic(type: measurement, properties: [.notify], value: nil, permissions: [])
|
||||
let service = CBMutableService(type: hrs, primary: true)
|
||||
service.characteristics = [characteristic]
|
||||
peripheral.add(service)
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||||
if let error { fail("add service: \(error.localizedDescription)") }
|
||||
peripheral.startAdvertising([CBAdvertisementDataLocalNameKey: "FC Test Strap",
|
||||
CBAdvertisementDataServiceUUIDsKey: [hrs]])
|
||||
}
|
||||
|
||||
func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
|
||||
if let error { fail("advertise: \(error.localizedDescription)") }
|
||||
FileHandle.standardError.write("Advertising FC Test Strap\n".data(using: .utf8)!)
|
||||
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.tick() }
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
||||
subscribed = true
|
||||
FileHandle.standardError.write("Central subscribed\n".data(using: .utf8)!)
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
|
||||
subscribed = false
|
||||
FileHandle.standardError.write("Central unsubscribed\n".data(using: .utf8)!)
|
||||
}
|
||||
|
||||
func tick() {
|
||||
guard subscribed else { return }
|
||||
if sent >= seconds { exit(0) }
|
||||
let (bpm, flags) = packet(sent)
|
||||
var bytes: [UInt8] = [flags, UInt8(bpm & 0xff)]
|
||||
if flags & 1 != 0 { bytes.append(UInt8(bpm >> 8)) }
|
||||
if manager.updateValue(Data(bytes), for: characteristic, onSubscribedCentrals: nil) {
|
||||
print(String(format: "%.3f,%d,%d", Date().timeIntervalSince1970, bpm, flags))
|
||||
fflush(stdout)
|
||||
sent += 1
|
||||
}
|
||||
}
|
||||
|
||||
func fail(_ message: String) -> Never {
|
||||
FileHandle.standardError.write("\(message)\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
let seconds = CommandLine.arguments.count > 1 ? Int(CommandLine.arguments[1]) ?? 60 : 60
|
||||
let strap = Strap(seconds: seconds)
|
||||
RunLoop.main.run()
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install or run our local-only tracking tools on the Frame.
|
||||
|
||||
python3 scripts/tracking-on-frame.py install
|
||||
python3 scripts/tracking-on-frame.py gaze --seconds 10
|
||||
python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000
|
||||
python3 scripts/tracking-on-frame.py heart --device AA:BB:CC:DD:EE:FF --panel
|
||||
python3 scripts/tracking-on-frame.py pulse --seconds 60 --show # experimental
|
||||
|
||||
FRAME_ALIAS overrides the SSH alias (default: frame). Runs in the foreground;
|
||||
Ctrl-C stops the reader. No service, autostart, sudo or SteamVR settings changes.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
REMOTE = '"$HOME/.local/share/frame-control/tracking"'
|
||||
INSTALL = '''set -eu
|
||||
base="$HOME/.local/share/frame-control/tracking"
|
||||
mkdir -p "$base"
|
||||
stage=$(mktemp -d "$base/.install.XXXXXX")
|
||||
trap 'rm -rf "$stage"' EXIT
|
||||
tar -xf - -C "$stage"
|
||||
cc -O2 -Wall -Wextra -Werror "$stage/gaze.c" \\
|
||||
-L/opt/steamvr/bin/linuxarm64 -Wl,-rpath,/opt/steamvr/bin/linuxarm64 \\
|
||||
-lopenxr_loader -o "$stage/gaze"
|
||||
chmod 700 "$stage/gaze" "$stage/tracking.py"
|
||||
chmod 600 "$stage/pulse.py"
|
||||
mv "$stage/gaze" "$stage/tracking.py" "$stage/pulse.py" "$base/"
|
||||
echo 'Installed Frame Control tracking tools (no service started).'
|
||||
'''
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ("install", "gaze", "heart", "pulse"):
|
||||
print(__doc__)
|
||||
return 2
|
||||
host = os.environ.get("FRAME_ALIAS", "frame")
|
||||
if not host or host.startswith("-"):
|
||||
raise ValueError("invalid SSH alias")
|
||||
ssh = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", host]
|
||||
if sys.argv[1] == "install":
|
||||
import tempfile
|
||||
source = Path(__file__).resolve().parents[1] / "frame" / "tracking"
|
||||
with tempfile.TemporaryFile() as archive:
|
||||
with tarfile.open(fileobj=archive, mode="w") as tar:
|
||||
for name in ("gaze.c", "tracking.py", "pulse.py"):
|
||||
tar.add(source / name, arcname=name)
|
||||
archive.seek(0)
|
||||
return subprocess.call(ssh + ["bash -c " + shlex.quote(INSTALL)], stdin=archive)
|
||||
# Allocate a tty so SSH forwards Ctrl-C and hangup to the foreground process.
|
||||
command = 'exec python3 ' + REMOTE + '/tracking.py ' + shlex.join(sys.argv[1:])
|
||||
return subprocess.call(ssh[:-1] + ["-tt", host, command])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac or Linux: drive a Windows 11 test VM for Frame Control's Windows build.
|
||||
# The VM is a dockur/windows container on another machine; this reaches it over
|
||||
# SSH through that machine. See docs/testing.md#windows-test-vm.
|
||||
#
|
||||
# Usage: scripts/windows-vm.sh <command> [args]
|
||||
# up | down | status start it (waits for SSH), shut Windows down cleanly, show state
|
||||
# ps '<PowerShell>' run PowerShell as the VM's user
|
||||
# put <file> [<dir>] copy a file in (default: the user's Downloads)
|
||||
# shot <out.png> save the VM's screen
|
||||
# click <x> <y> click screen pixel x,y in the signed-in session
|
||||
# scroll <x> <y> <n> turn the wheel n notches at x,y (negative scrolls down)
|
||||
# keys <key>... type QEMU key names: a, shift-a, ret, tab, esc, spc ...
|
||||
# frame-key add|remove let the VM's Frame Control key into the headset (`frame`
|
||||
# alias) for a test run, then take it out again
|
||||
#
|
||||
# Set WINVM_HOST to the ssh alias of the machine running the container. Optional:
|
||||
# WINVM_CONTAINER (frame-winvm), WINVM_USER (frame), WINVM_PORT (2222: the VM's
|
||||
# sshd, published on that machine's loopback), WINVM_KEY (~/.ssh/id_ed25519_winvm).
|
||||
set -euo pipefail
|
||||
setopt extendedglob
|
||||
|
||||
host=${WINVM_HOST:?set WINVM_HOST to the ssh alias of the machine running the VM}
|
||||
ctr=${WINVM_CONTAINER:-frame-winvm}
|
||||
user=${WINVM_USER:-frame}
|
||||
port=${WINVM_PORT:-2222}
|
||||
key=${WINVM_KEY:-$HOME/.ssh/id_ed25519_winvm}
|
||||
opts=(-o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new
|
||||
-o UserKnownHostsFile=${TMPDIR:-/tmp}/windows-vm-known_hosts -i $key -J $host)
|
||||
TAG=windows-vm-test # comment on the VM's key in the headset's authorized_keys
|
||||
|
||||
die() { print -u2 "windows-vm: $*"; exit 1 }
|
||||
int() { [[ $1 == (-|)<-99999> ]] || die "not a whole number (up to 99999): $1" }
|
||||
# These go into commands run by a shell on the other machine, so keep them plain.
|
||||
for v in $host $ctr $user; do [[ $v == [A-Za-z0-9_.]##[A-Za-z0-9_.-]# ]] || die "not a plain name: $v"; done
|
||||
[[ $port == <1-65535> ]] || die "WINVM_PORT isn't a port: $port"
|
||||
|
||||
# Windows' OpenSSH waits for stdin to close, so it always gets /dev/null. The
|
||||
# script travels UTF-16 base64-encoded, so no quoting survives two shells.
|
||||
vm_ps() {
|
||||
local b64=$(print -rn -- "\$ProgressPreference = 'SilentlyContinue'"$'\n'"$1" |
|
||||
iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n')
|
||||
ssh $opts -p $port $user@127.0.0.1 "powershell -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand $b64" </dev/null
|
||||
}
|
||||
|
||||
# QEMU's monitor inside the container: one command per line on stdin.
|
||||
monitor() { ssh $host "docker exec -i $ctr nc -q 1 -U /run/shm/monitor.sock" >/dev/null }
|
||||
|
||||
# Input has to come from the signed-in desktop session, not SSH's session 0, so
|
||||
# a scheduled task running as the user replays one click or wheel turn written
|
||||
# to input.txt, then deletes the file to say it's done. Any error stops it
|
||||
# before that, so the caller times out instead of reporting a click.
|
||||
INPUT_PS1='$ErrorActionPreference = "Stop"
|
||||
$a = (Get-Content "$PSScriptRoot\input.txt").Trim() -split "\s+"
|
||||
Add-Type -Namespace WinVm -Name Input -MemberDefinition @"
|
||||
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, System.IntPtr extra);
|
||||
"@
|
||||
[WinVm.Input]::SetProcessDPIAware() | Out-Null
|
||||
if (-not [WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1])) { throw "SetCursorPos failed" }
|
||||
Start-Sleep -Milliseconds 150
|
||||
if ($a[2] -eq "click") {
|
||||
[WinVm.Input]::mouse_event(0x2, 0, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 60
|
||||
[WinVm.Input]::mouse_event(0x4, 0, 0, 0, [IntPtr]::Zero)
|
||||
} else { [WinVm.Input]::mouse_event(0x800, 0, 0, 120 * [int]$a[3], [IntPtr]::Zero) }
|
||||
Remove-Item "$PSScriptRoot\input.txt"'
|
||||
|
||||
pointer() { # x y click|wheel [notches]
|
||||
local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n')
|
||||
vm_ps '$ErrorActionPreference = "Stop"
|
||||
$d = Join-Path $env:LOCALAPPDATA "windows-vm"; $req = "$d\input.txt"; $mine = $false
|
||||
# Giving up: end the helper first, or it could wake up and act in a later request.
|
||||
function Abandon {
|
||||
Stop-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue
|
||||
foreach ($i in 1..25) {
|
||||
if ((Get-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue).State -ne "Running") { break }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
Remove-Item $req -ErrorAction SilentlyContinue
|
||||
}
|
||||
trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($mine) { Abandon }; exit 1 }
|
||||
# One request at a time: the helper, the task and input.txt are shared. Windows
|
||||
# frees the mutex when this process ends, however it ends.
|
||||
$lock = New-Object Threading.Mutex($false, "windows-vm-input")
|
||||
try { $mine = $lock.WaitOne(15000) } catch [Threading.AbandonedMutexException] { $mine = $true }
|
||||
if (-not $mine) { [Console]::Error.WriteLine("windows-vm: another click or scroll is still in progress"); exit 1 }
|
||||
New-Item -ItemType Directory -Force $d | Out-Null
|
||||
[IO.File]::WriteAllText($req, "'"$*"'")
|
||||
[IO.File]::WriteAllText("$d\input.ps1", [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'$b64'")))
|
||||
$act = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$d\input.ps1`""
|
||||
$who = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
|
||||
Register-ScheduledTask -TaskName WindowsVmInput -Action $act -Principal $who -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName WindowsVmInput
|
||||
foreach ($i in 1..50) { if (-not (Test-Path $req)) { exit 0 }; Start-Sleep -Milliseconds 200 }
|
||||
Abandon
|
||||
[Console]::Error.WriteLine("windows-vm: no input after 10 s: is $env:USERNAME signed in on the VM screen, and is x,y on it?"); exit 1'
|
||||
}
|
||||
|
||||
(( $# )) || die "usage: see the top of $0"
|
||||
cmd=$1; shift
|
||||
case $cmd in
|
||||
up)
|
||||
ssh $host "docker start $ctr" >/dev/null
|
||||
for i in {1..60}; do
|
||||
vm_ps 'exit 0' 2>/dev/null && { print "up"; exit 0 }
|
||||
sleep 5
|
||||
done
|
||||
die "Windows didn't answer on SSH within 5 minutes" ;;
|
||||
down) # the container turns SIGTERM into an ACPI shutdown and waits for Windows
|
||||
ssh $host "docker stop -t 150 $ctr" >/dev/null && print "down" ;;
|
||||
status) ssh $host "docker ps -a --filter 'name=^$ctr\$' --format '{{.Names}}: {{.Status}}'" ;;
|
||||
ps) (( $# == 1 )) || die "usage: ps '<PowerShell>'"; vm_ps "$1" ;;
|
||||
put)
|
||||
[[ -f ${1:-} ]] || die "usage: put <file> [<dir>]"
|
||||
scp -q $opts -P $port $1 "$user@127.0.0.1:${2:-C:/Users/$user/Downloads}/${1:t}" </dev/null ;;
|
||||
shot)
|
||||
(( $# == 1 )) || die "usage: shot <out.png>"
|
||||
print "screendump /tmp/windows-vm-shot.ppm" | monitor
|
||||
sleep 1
|
||||
ssh $host "docker exec $ctr sh -c 'cat /tmp/windows-vm-shot.ppm && rm /tmp/windows-vm-shot.ppm'" | python3 -c '
|
||||
import re, struct, sys, zlib
|
||||
d = sys.stdin.buffer.read()
|
||||
m = re.match(rb"P6\s+(\d+)\s+(\d+)\s+255\s", d) or sys.exit("windows-vm: no screen dump")
|
||||
w, h = int(m[1]), int(m[2]); px = d[m.end():]
|
||||
raw = b"".join(b"\0" + px[y * w * 3:(y + 1) * w * 3] for y in range(h))
|
||||
chunk = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b))
|
||||
open(sys.argv[1], "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
|
||||
' $1
|
||||
print $1 ;;
|
||||
click) (( $# == 2 )) || die "usage: click <x> <y>"; int $1; int $2; pointer $1 $2 click ;;
|
||||
scroll) (( $# == 3 )) || die "usage: scroll <x> <y> <n>"; int $1; int $2; int $3; pointer $1 $2 wheel $3 ;;
|
||||
keys)
|
||||
(( $# )) || die "usage: keys <key>..."
|
||||
for k; do [[ $k == [a-z0-9_.,/=-]## ]] || die "not a QEMU key name: $k"; done
|
||||
for k; do print "sendkey $k"; done | monitor ;;
|
||||
frame-key)
|
||||
[[ ${1:-} == (add|remove) ]] || die "usage: frame-key add|remove"
|
||||
pub=(${=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r')})
|
||||
[[ ${pub[1]:-} == ssh-ed25519 && ${pub[2]:-} == [A-Za-z0-9+/=]## ]] ||
|
||||
die "the VM has no Frame Control key yet: run Set Up Connection in the app first"
|
||||
# $1: add|remove, $2: the key's base64, $3: the exact line this script owns.
|
||||
ssh frame "sh -s -- $1 '$pub[2]' '$pub[1] $pub[2] $TAG'" <<'EOF'
|
||||
f=~/.ssh/authorized_keys
|
||||
if [ "$1" = add ]; then
|
||||
if grep -qxF "$3" "$f"; then exit 0; fi
|
||||
if grep -qF "$2" "$f"; then echo "the headset already trusts this key through another entry; left as it is"; exit 0; fi
|
||||
[ -z "$(tail -c 1 "$f")" ] || echo >> "$f" # a last line without a newline would swallow ours
|
||||
echo "$3" >> "$f"
|
||||
else
|
||||
t=$(mktemp "$f.XXXXXX") || exit 1
|
||||
grep -vxF "$3" "$f" > "$t" # 0: lines left, 1: none left, more: couldn't read or write
|
||||
if [ $? -gt 1 ] || ! chmod 600 "$t" || ! mv "$t" "$f"; then
|
||||
rm -f "$t"; echo "couldn't rewrite $f; it's unchanged" >&2; exit 1
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
print "frame-key $1: done" ;;
|
||||
*) die "unknown command: $cmd (see the top of $0)" ;;
|
||||
esac
|
||||
@@ -0,0 +1,79 @@
|
||||
// The store's "Add a source" login handling and GitHub token field, run in node against
|
||||
// the real functions from ui/index.html. A login typed into the address must leave the
|
||||
// address field (into the login fields) before anything is sent, and stay out of it.
|
||||
import { readFileSync } from "fs";
|
||||
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
|
||||
const lines = src.split("\n");
|
||||
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
|
||||
|
||||
function grab(name) {
|
||||
const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
|
||||
if (start < 0) fail(`no top-level ${name} in index.html`);
|
||||
let depth = 0;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
|
||||
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
|
||||
}
|
||||
fail(`unbalanced ${name}`);
|
||||
}
|
||||
const { moveLogin, sourceToken } = new Function(
|
||||
"const esc = s => String(s).replace(/[&<>\"']/g, c => `&#${c.charCodeAt(0)};`); const storeName = s => s;\n" +
|
||||
grab("decodeLenient") + grab("moveLogin") + grab("sourceToken") + "return { moveLogin, sourceToken };")();
|
||||
|
||||
function form(url, username = "", password = "") {
|
||||
const details = { open: false };
|
||||
const field = value => ({ value, closest: () => details });
|
||||
return { details, elements: { url: field(url), username: field(username), password: field(password) } };
|
||||
}
|
||||
|
||||
let f = form("https://alice:s3cr%40t@repo.example/fdroid/repo");
|
||||
moveLogin(f);
|
||||
if (f.elements.url.value !== "https://repo.example/fdroid/repo") fail("address still holds the login: " + f.elements.url.value);
|
||||
if (f.elements.username.value !== "alice" || f.elements.password.value !== "s3cr@t") fail("login not moved into its fields");
|
||||
if (!f.details.open) fail("login fields not shown");
|
||||
|
||||
f = form("https://alice:fromurl@repo.example/repo", "typed", "typedpw");
|
||||
moveLogin(f);
|
||||
if (f.elements.url.value.includes("fromurl") || f.elements.username.value !== "typed" || f.elements.password.value !== "typedpw")
|
||||
fail("typed login replaced, or address kept the login");
|
||||
|
||||
// Undecodable logins and unusual spellings the URL parser accepts still lose their login; undecodable bytes stay as typed.
|
||||
for (const [url, address, user, pass] of [
|
||||
["https://alice:100%done@repo.example/private/repo", "https://repo.example/private/repo", "alice", "100%done"],
|
||||
["https:\t//alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
|
||||
["ht\ntps://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
|
||||
["https:alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
|
||||
["\u0000https://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
|
||||
["https://alice:%FF@repo.example/private/repo", "https://repo.example/private/repo", "alice", "%FF"],
|
||||
["https://al%20ice:p%40ss:word@repo.example/r", "https://repo.example/r", "al ice", "p@ss:word"],
|
||||
["https://alice:p%FF\u00e4@repo.example/repo", "https://repo.example/repo", "alice", "p%FF\u00e4"],
|
||||
["fdroidrepos://alice:pw@repo.example/repo?fingerprint=AB", "fdroidrepos://repo.example/repo?fingerprint=AB", "alice", "pw"],
|
||||
["https://alice@repo.example/repo", "https://repo.example/repo", "alice", ""],
|
||||
]) {
|
||||
f = form(url);
|
||||
moveLogin(f);
|
||||
if (f.elements.url.value !== address) fail(`address kept the login: ${url} -> ${f.elements.url.value}`);
|
||||
if (f.elements.username.value !== user || f.elements.password.value !== pass)
|
||||
fail(`login for ${url}: ${f.elements.username.value} / ${f.elements.password.value}`);
|
||||
}
|
||||
|
||||
// An address the parser rejects that may hold a login is refused and left as typed, never sent.
|
||||
for (const url of ["https://alice\\name:pw@repo.example/repo", "https:\\\\alice:s3cret@repo.example/repo", "fdroidrepos://alice:p\\w@repo.example/repo", "https://alice:s3cret@repo example/repo", "https:alice:s3cret@repo example/repo", "https://repo.example:bad\\a@b"]) {
|
||||
f = form(url);
|
||||
if (moveLogin(f) !== false) fail("accepted an invalid address with a login: " + url);
|
||||
if (f.elements.username.value || f.elements.password.value) fail("guessed a login from an invalid address: " + url);
|
||||
}
|
||||
|
||||
for (const url of ["https://repo.example/repo", "not a url", "https://repo.example/a@b", "fdroidrepos:///a@b", "fdroidrepos:/a@b", "https://repo.example/r?x=a@b#c@d"]) {
|
||||
f = form(url);
|
||||
moveLogin(f);
|
||||
if (f.elements.url.value !== url || f.elements.username.value) fail("changed an address without a login: " + url);
|
||||
}
|
||||
|
||||
const html = sourceToken({ id: "github", name: "GitHub releases", token: { set: true, from: "saved", rejected: true } });
|
||||
if (!html.includes("rejected") || !html.includes('data-clear-token="github"')) fail("token row: " + html);
|
||||
if (sourceToken({ id: "fdroid", name: "F-Droid" }) !== "") fail("token field on a source without one");
|
||||
const slow = "https://a:" + "%FF".repeat(4000) + "@repo.example/r", t0 = Date.now();
|
||||
f = form(slow); moveLogin(f);
|
||||
if (Date.now() - t0 > 200 || f.elements.password.value !== "%FF".repeat(4000)) fail("decoding malformed escapes is slow or wrong");
|
||||
console.log("ok");
|
||||
@@ -325,7 +325,7 @@ class EndpointTests(SettingsTest):
|
||||
mod.add_repo.side_effect = SourceError('repository fingerprint mismatch')
|
||||
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
|
||||
self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix
|
||||
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'):
|
||||
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://repo.example:x/'):
|
||||
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400)
|
||||
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
|
||||
mod.remove_repo.assert_called_once_with(source_id='fdroid')
|
||||
|
||||
@@ -81,7 +81,7 @@ class Repositories(unittest.TestCase):
|
||||
state.clear()
|
||||
self.addCleanup(state.clear)
|
||||
|
||||
def fetch(self, url, path, maximum):
|
||||
def fetch(self, url, path, maximum, auth=None):
|
||||
name = url.rsplit('/', 1)[-1]
|
||||
if self.v1 and name == 'entry.jar':
|
||||
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
|
||||
@@ -311,7 +311,7 @@ class Repositories(unittest.TestCase):
|
||||
source = self.add()
|
||||
other = dict(source, id='other-repo')
|
||||
started, release = threading.Event(), threading.Event()
|
||||
def fetch(url, path, maximum):
|
||||
def fetch(url, path, maximum, auth=None):
|
||||
if threading.current_thread().name == 'slow':
|
||||
started.set()
|
||||
release.wait(5)
|
||||
@@ -355,7 +355,7 @@ class Repositories(unittest.TestCase):
|
||||
self.files['entry.jar'], _ = entry_jar(50)
|
||||
source = fdroid.add_repo(URL)
|
||||
jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]}
|
||||
def fetch(url, path, maximum):
|
||||
def fetch(url, path, maximum, auth=None):
|
||||
name = threading.current_thread().name
|
||||
if name in jars and url.endswith('entry.jar'):
|
||||
Path(path).write_bytes(jars[name])
|
||||
@@ -401,7 +401,7 @@ class Repositories(unittest.TestCase):
|
||||
source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None)
|
||||
self.v1 = True
|
||||
inner = []
|
||||
def fetch(url, path, maximum):
|
||||
def fetch(url, path, maximum, auth=None):
|
||||
if url.endswith('index-v1.jar') and not inner:
|
||||
inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now
|
||||
self.v1 = False
|
||||
@@ -471,7 +471,7 @@ class Repositories(unittest.TestCase):
|
||||
source = self.add()
|
||||
self.expire(source)
|
||||
release = threading.Event()
|
||||
def slow(url, path, maximum):
|
||||
def slow(url, path, maximum, auth=None):
|
||||
release.wait(5)
|
||||
self.fetch(url, path, maximum)
|
||||
self.fetch_mock.side_effect = slow
|
||||
@@ -528,7 +528,7 @@ class Repositories(unittest.TestCase):
|
||||
self.expire(source)
|
||||
before = self.fetch_mock.call_count
|
||||
release = __import__('threading').Event()
|
||||
def slow(url, path, maximum):
|
||||
def slow(url, path, maximum, auth=None):
|
||||
release.wait(5)
|
||||
self.fetch(url, path, maximum)
|
||||
self.fetch_mock.side_effect = slow
|
||||
|
||||
@@ -121,7 +121,27 @@ class LauncherTests(unittest.TestCase):
|
||||
FIXTURES = ROOT / 'tests/fixtures/library'
|
||||
|
||||
|
||||
def wait_for_idle_resolvers(timeout=10):
|
||||
# Name lookups that outlast their deadline give their slot back from their own thread,
|
||||
# which a busy runner may not schedule before the next test; wait until all are back.
|
||||
from apk_sources import _images
|
||||
held = []
|
||||
try:
|
||||
end = time.monotonic() + timeout
|
||||
while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())):
|
||||
held.append(1)
|
||||
if len(held) == 4:
|
||||
return
|
||||
raise AssertionError('artwork name lookups from an earlier test are still running')
|
||||
finally:
|
||||
for _ in held:
|
||||
_images._resolvers.release()
|
||||
|
||||
|
||||
class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
wait_for_idle_resolvers()
|
||||
|
||||
def test_source_inputs_and_url(self):
|
||||
from apk_sources import _images
|
||||
data = (FIXTURES / 'icon.png').read_bytes()
|
||||
@@ -232,10 +252,7 @@ class ArtworkTests(unittest.TestCase):
|
||||
self.assertEqual(sum('Too many' in e for e in errors), 2)
|
||||
finally:
|
||||
gate.set()
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
|
||||
time.sleep(0.01)
|
||||
_images._resolvers.release() # the stuck lookups finished and gave their slots back
|
||||
wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back
|
||||
|
||||
def test_resolver_slot_released_when_thread_cannot_start(self):
|
||||
from apk_sources import _images
|
||||
@@ -243,10 +260,14 @@ class ArtworkTests(unittest.TestCase):
|
||||
for _ in range(6):
|
||||
with self.assertRaises(RuntimeError):
|
||||
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
|
||||
for _ in range(4): # every slot came back
|
||||
self.assertTrue(_images._resolvers.acquire(blocking=False))
|
||||
for _ in range(4):
|
||||
_images._resolvers.release()
|
||||
held = 0
|
||||
try:
|
||||
while held < 4 and _images._resolvers.acquire(blocking=False):
|
||||
held += 1
|
||||
self.assertEqual(held, 4) # every slot came back
|
||||
finally:
|
||||
for _ in range(held): # even on failure, so later tests don't inherit the leak
|
||||
_images._resolvers.release()
|
||||
|
||||
def test_deadline_covers_name_resolution(self):
|
||||
import threading
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""VR mod (UEVR) checks that need no headset or network: a fake Steam library on disk.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_mods # noqa: E402
|
||||
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
|
||||
|
||||
APPID = 1067310
|
||||
|
||||
|
||||
def zip_bytes(files, links=()):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
for name in links:
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
z.writestr(info, "/etc/passwd")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "the helper runs on the Frame (Linux): /proc, flock and POSIX prefix paths")
|
||||
class FakeLibrary(unittest.TestCase):
|
||||
"""A Steam library with Gravitas's real layout, and pinned downloads served from memory."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
home = Path(tmp.name)
|
||||
self.steam = home / ".local/share/Steam"
|
||||
apps = self.steam / "steamapps"
|
||||
(apps / "common/Gravitas/SkyArk/Binaries/Win64").mkdir(parents=True)
|
||||
(apps / "common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").write_bytes(b"MZ" * 100)
|
||||
(apps / "common/Gravitas/Drop.exe").write_bytes(b"MZ")
|
||||
self.prefix = apps / f"compatdata/{APPID}/pfx"
|
||||
for sub in ("Roaming", "Local"):
|
||||
(self.prefix / "drive_c/users/steamuser/AppData" / sub).mkdir(parents=True)
|
||||
(apps / f"appmanifest_{APPID}.acf").write_text(
|
||||
f'"AppState"\n{{\n\t"appid"\t\t"{APPID}"\n\t"name"\t\t"Gravitas"\n\t"installdir"\t\t"Gravitas"\n}}\n')
|
||||
for name, value in (("STEAM", self.steam), ("CACHE", home / ".cache/frame-control/mods"),
|
||||
("RECEIPTS", home / ".local/share/frame-control/mods")):
|
||||
p = mock.patch.object(frame_mods, name, value)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.running = None
|
||||
p = mock.patch.object(frame_mods, "game_pid", side_effect=lambda exe: self.running)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ",
|
||||
"openvr_api.dll": b"MZ"})}
|
||||
self.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"})
|
||||
self.pin(self.downloads)
|
||||
|
||||
def pin(self, downloads):
|
||||
"""Serve these bytes and pin their real hashes in place of the published ones."""
|
||||
for name in ("UEVR", "PYTHON"):
|
||||
pinned = getattr(frame_mods, name)
|
||||
p = mock.patch.object(frame_mods, name,
|
||||
dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest()))
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
p = mock.patch.object(frame_mods.urllib.request, "urlopen",
|
||||
side_effect=lambda url, timeout: io.BytesIO(downloads[url]))
|
||||
self.urlopen = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def managed(self):
|
||||
return self.prefix / "drive_c/frame-control"
|
||||
|
||||
|
||||
class Status(FakeLibrary):
|
||||
def test_unreal_game_is_eligible_and_not_installed(self):
|
||||
st = frame_mods.status(APPID)
|
||||
self.assertEqual((st["eligible"], st["exe"], st["installed"], st["running"], st["prefix"]),
|
||||
(True, "Drop-Win64-Shipping.exe", False, False, True))
|
||||
|
||||
def test_game_without_unreal_binary_is_refused(self):
|
||||
(self.steam / "steamapps/common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").unlink()
|
||||
self.assertFalse(frame_mods.status(APPID)["eligible"])
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "Unreal Engine"):
|
||||
frame_mods.install(APPID)
|
||||
self.urlopen.assert_not_called()
|
||||
|
||||
def test_uninstalled_game(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
|
||||
frame_mods.status(620)
|
||||
|
||||
|
||||
class InstallUninstall(FakeLibrary):
|
||||
def test_install_then_uninstall_leaves_prefix_as_found(self):
|
||||
before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
|
||||
res = frame_mods.install(APPID)
|
||||
self.assertTrue(res["installed"])
|
||||
self.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").is_file())
|
||||
self.assertTrue((self.managed() / "python-3.14.7/python.exe").is_file())
|
||||
self.assertIn("UEVRBackend.dll", (self.managed() / "python-3.14.7/frame_inject.py").read_text())
|
||||
self.assertFalse(list(self.managed().glob(".staging-*")))
|
||||
# What UEVR writes on its first injection.
|
||||
roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
|
||||
roaming.mkdir(parents=True)
|
||||
(roaming / "log.txt").write_text("x")
|
||||
(self.prefix / "drive_c/users/steamuser/AppData/Local/praydog/x").mkdir(parents=True)
|
||||
frame_mods.uninstall(APPID)
|
||||
after = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
|
||||
self.assertEqual(after, before)
|
||||
self.assertFalse(frame_mods.CACHE.exists(), "the downloads go once no game uses them")
|
||||
self.assertIsNone(frame_mods.read_receipt(APPID))
|
||||
|
||||
def test_existing_uevr_settings_survive_uninstall(self):
|
||||
profile = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
|
||||
profile.mkdir(parents=True)
|
||||
(profile / "config.txt").write_text("mine")
|
||||
frame_mods.install(APPID)
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertEqual((profile / "config.txt").read_text(), "mine")
|
||||
|
||||
def test_second_install_is_a_no_op(self):
|
||||
frame_mods.install(APPID)
|
||||
calls = self.urlopen.call_count
|
||||
self.assertIn("already installed", frame_mods.install(APPID)["message"])
|
||||
self.assertEqual(self.urlopen.call_count, calls)
|
||||
|
||||
def test_refuses_while_game_runs(self):
|
||||
self.running = 4242
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "running"):
|
||||
frame_mods.install(APPID)
|
||||
self.running = None
|
||||
frame_mods.install(APPID)
|
||||
self.running = 4242
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "running"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
|
||||
|
||||
def test_needs_a_prefix(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.prefix.parent)
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "play it once"):
|
||||
frame_mods.install(APPID)
|
||||
|
||||
def test_receipt_cannot_point_outside_the_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
victim = self.steam / "steamapps/common/Gravitas"
|
||||
r["remove"].append(str(self.prefix / "../../../common/Gravitas"))
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue(victim.is_dir())
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad")
|
||||
|
||||
|
||||
def test_receipt_cannot_delete_other_things_in_the_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
users = self.prefix / "drive_c/users"
|
||||
r["remove"].append(str(users))
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue(users.is_dir())
|
||||
|
||||
def test_failed_removal_keeps_the_receipt(self):
|
||||
frame_mods.install(APPID)
|
||||
with mock.patch.object(frame_mods.shutil, "rmtree"): # e.g. a permission error, swallowed
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "couldn't remove"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertIsNotNone(frame_mods.read_receipt(APPID), "Remove can be tried again")
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertFalse(self.managed().exists())
|
||||
|
||||
def test_receipt_cannot_name_another_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
r["prefix"] = "/"
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't app"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
|
||||
|
||||
|
||||
class Downloads(FakeLibrary):
|
||||
def test_hash_mismatch_deletes_and_installs_nothing(self):
|
||||
self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRBackend.dll": b"tampered"})
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
|
||||
self.assertFalse(self.managed().exists())
|
||||
self.assertIsNone(frame_mods.read_receipt(APPID))
|
||||
|
||||
def test_archive_escaping_its_folder_is_refused(self):
|
||||
for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}),
|
||||
zip_bytes({"UEVRBackend.dll": b"MZ"}, links=["openvr_api.dll"])):
|
||||
downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad})
|
||||
self.pin(downloads)
|
||||
with self.subTest(), self.assertRaises(frame_mods.Fail):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse((self.prefix / "drive_c/evil.dll").exists())
|
||||
self.assertFalse(list(self.managed().glob("*")) if self.managed().exists() else [])
|
||||
import shutil
|
||||
shutil.rmtree(frame_mods.CACHE, ignore_errors=True)
|
||||
|
||||
def test_oversized_archive_is_refused(self):
|
||||
with mock.patch.object(frame_mods, "MAX_UNPACKED", 3):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "more than expected"):
|
||||
frame_mods.install(APPID)
|
||||
|
||||
|
||||
def test_oversized_download_is_stopped(self):
|
||||
with mock.patch.object(frame_mods, "MAX_DOWNLOAD", 10):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "bigger than expected"):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse(list(frame_mods.CACHE.glob("*")))
|
||||
|
||||
|
||||
class Actions(FakeLibrary):
|
||||
def test_one_change_at_a_time(self):
|
||||
import fcntl
|
||||
lock = Path(tempfile.mkdtemp()) / "mods.lock"
|
||||
with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held:
|
||||
fcntl.flock(held, fcntl.LOCK_EX)
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "another mod action"):
|
||||
frame_mods.main(["install", str(APPID)])
|
||||
self.assertFalse(frame_mods.status(APPID)["installed"])
|
||||
self.assertEqual(frame_mods.main(["status", str(APPID)])["appid"], APPID, "status never waits")
|
||||
|
||||
def test_zero_frames_is_an_answer(self):
|
||||
stats = json.dumps([{"key": f"steam.app.{APPID}", "frame_submits": 0}, {"operation": "status"}])
|
||||
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=stats)):
|
||||
self.assertEqual(frame_mods.wait("frames", 5, lambda: frame_mods.frame_submits(APPID)), 0)
|
||||
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout='{"odd": 1}')):
|
||||
self.assertIsNone(frame_mods.frame_submits(APPID))
|
||||
|
||||
|
||||
class Start(FakeLibrary):
|
||||
def environ(self, text):
|
||||
"""The game's /proc/<pid>/environ; every other file reads normally."""
|
||||
real = frame_mods.Path.read_text
|
||||
return mock.patch.object(frame_mods.Path, "read_text", autospec=True, side_effect=lambda path, *a, **k:
|
||||
text if str(path).startswith("/proc/") else real(path, *a, **k))
|
||||
|
||||
def test_needs_install_first(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
|
||||
frame_mods.start(APPID)
|
||||
|
||||
def run_start(self, reply, loaded=(False, True, True, True)):
|
||||
frame_mods.install(APPID)
|
||||
self.running = 4242
|
||||
env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0"
|
||||
with self.environ(env), \
|
||||
mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \
|
||||
mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \
|
||||
mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \
|
||||
mock.patch.object(frame_mods, "frame_submits", return_value=7), \
|
||||
mock.patch.object(frame_mods.time, "sleep"):
|
||||
return frame_mods.start(APPID), run
|
||||
|
||||
def test_injects_with_the_games_wine_settings(self):
|
||||
res, run = self.run_start('noise\n{"pid": 320}\n')
|
||||
self.assertEqual((res["frame_submits"], res["message"]), (7, "UEVR injected into Gravitas; SteamVR is receiving its frames"))
|
||||
cmd, env = run.call_args.args[0], run.call_args.kwargs["env"]
|
||||
self.assertEqual(cmd[:2], ["wine", "C:\\frame-control\\python-3.14.7\\python.exe"])
|
||||
self.assertEqual(cmd[3:], ["Drop-Win64-Shipping.exe", "C:\\frame-control\\uevr-1.05",
|
||||
"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\Drop-Win64-Shipping"])
|
||||
self.assertEqual(env["WINEPREFIX"], "/x")
|
||||
self.assertNotIn("WINESERVERSOCKET", env, "an inherited fd number means nothing to a new process")
|
||||
self.assertIn("HOME", env, "fontconfig needs a HOME for its cache")
|
||||
|
||||
def test_reports_the_injection_error(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "couldn't open"):
|
||||
self.run_start('{"error": "couldn\'t open Drop-Win64-Shipping.exe (5)"}\n')
|
||||
|
||||
def test_success_is_checked_in_the_game(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "has no UEVRBackend.dll"):
|
||||
with mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)):
|
||||
self.run_start('{"pid": 320}\n', loaded=[False] * 40)
|
||||
|
||||
def test_inject_script_is_valid_python(self):
|
||||
compile(frame_mods.INJECT_PY, "frame_inject.py", "exec")
|
||||
|
||||
|
||||
class Helper(unittest.TestCase):
|
||||
@unittest.skipIf(sys.platform == "win32", "uninstall takes the flock lock, which only exists on the Frame (Linux)")
|
||||
def test_unexpected_errors_are_still_json(self):
|
||||
# A receipt from another version, without "exe": a KeyError, not a traceback.
|
||||
script = (ROOT / "ui" / "frame_mods.py").read_text().replace(
|
||||
"def read_receipt(appid):", "def read_receipt(appid):\n return {'prefix': '/x'}\n\n\ndef _unused(appid):")
|
||||
out = subprocess.run([sys.executable, "-", "uninstall", "1"], input=script,
|
||||
capture_output=True, text=True, timeout=30, env={"HOME": tempfile.mkdtemp()})
|
||||
self.assertEqual(out.returncode, 1, out.stderr)
|
||||
self.assertIn("KeyError", json.loads(out.stdout)["error"])
|
||||
|
||||
def test_bad_usage_prints_json_error(self):
|
||||
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
|
||||
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(out.returncode, 1, args)
|
||||
self.assertIn("error", json.loads(out.stdout), args)
|
||||
|
||||
|
||||
class ModRoutes(test_server.ServerGuards):
|
||||
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
|
||||
|
||||
def test_mod_input_validation(self):
|
||||
for body in ({"action": "install", "appid": "1; reboot"}, {"action": "install", "appid": ""},
|
||||
{"action": "delete", "appid": "1067310"}, {"appid": "1067310"}):
|
||||
status, payload = self.post("/api/mods", body)
|
||||
self.assertEqual(status, 400, f"{body} -> {payload}")
|
||||
|
||||
def test_needs_custom_header(self):
|
||||
self.assertEqual(self.request("POST", "/api/mods", {"action": "status", "appid": "1"})[0], 403)
|
||||
|
||||
def test_runs_the_mods_helper_with_its_timeout(self):
|
||||
import server
|
||||
with mock.patch.object(server, "steam_frame", return_value={"ok": 1}) as run:
|
||||
self.assertEqual(server.mods({"action": "install", "appid": "1067310"}), {"ok": 1})
|
||||
run.assert_called_once_with("install", "1067310", timeout=600, script="frame_mods.py")
|
||||
|
||||
|
||||
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
|
||||
setattr(ModRoutes, name, None)
|
||||
@@ -0,0 +1,472 @@
|
||||
"""Eye-camera pulse estimate and the heart-rate comparison tool; no headset needed."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
pulse = load("pulse", "frame/tracking/pulse.py")
|
||||
check = load("heart_check", "scripts/heart-check.py")
|
||||
|
||||
|
||||
def synthetic(bpm, pulsing, seconds=40, fps=30, patches=48, noise=0.004, seed=7):
|
||||
"""Patch brightness with a faint pulse in `pulsing` patches, plus sensor
|
||||
noise, slow drift, blinks in some patches and eye movement in others."""
|
||||
rng = random.Random(seed)
|
||||
times = [i / fps for i in range(seconds * fps)]
|
||||
values = {}
|
||||
for k in range(patches):
|
||||
base, amplitude, trace = 40 + k, 0.003 if k < pulsing else 0.0, []
|
||||
for t in times:
|
||||
v = base * (1 + amplitude * math.sin(2 * math.pi * bpm / 60 * t)
|
||||
+ noise * rng.gauss(0, 1) + 0.02 * math.sin(0.1 * t + k))
|
||||
if k % 8 == 7 and int(t * 10) % 47 == 0:
|
||||
v *= 0.5 # blink
|
||||
if k % 8 == 6 and int(t * 10) % 23 == 0:
|
||||
v *= 1.3 # frequent eye movement
|
||||
trace.append(v)
|
||||
values[k] = trace
|
||||
return times, values
|
||||
|
||||
|
||||
class Estimate(unittest.TestCase):
|
||||
def test_finds_pulse(self):
|
||||
for bpm in (58, 72, 115):
|
||||
with self.subTest(bpm=bpm):
|
||||
result = pulse.estimate(*synthetic(bpm, 16))
|
||||
self.assertAlmostEqual(result["bpm"], bpm, delta=1.5)
|
||||
self.assertTrue(pulse.reliable(result))
|
||||
self.assertTrue(all(abs(b - bpm) <= 2 for _, b in result["series"]))
|
||||
|
||||
def test_noise_and_blinks_are_not_a_pulse(self):
|
||||
result = pulse.estimate(*synthetic(72, 0))
|
||||
self.assertFalse(pulse.reliable(result))
|
||||
|
||||
def test_frequent_spike_patches_are_dropped(self):
|
||||
times, values = synthetic(72, 16)
|
||||
result = pulse.estimate(times, values)
|
||||
self.assertLess(result["usable"], len(values))
|
||||
|
||||
def test_needs_enough_frames(self):
|
||||
with self.assertRaises(ValueError):
|
||||
pulse.estimate(*synthetic(72, 16, seconds=5))
|
||||
times, values = synthetic(72, 16)
|
||||
with self.assertRaises(ValueError):
|
||||
pulse.estimate(times, {k: [0.0] * len(times) for k in values}) # all dark
|
||||
|
||||
def test_series_times(self):
|
||||
times, values = synthetic(72, 16, seconds=20)
|
||||
series = pulse.estimate(times, values)["series"]
|
||||
self.assertAlmostEqual(series[0][0], pulse.WINDOW, delta=0.1)
|
||||
self.assertEqual(len(series), 20 - int(pulse.WINDOW) + 1)
|
||||
|
||||
def test_flat_patches_do_not_rank_first(self):
|
||||
times, values = synthetic(72, 16)
|
||||
values["flat"] = [100.0] * len(times)
|
||||
result = pulse.estimate(times, values)
|
||||
self.assertAlmostEqual(result["bpm"], 72, delta=1.5)
|
||||
self.assertEqual(pulse.snr([(60.0, 0.0), (61.0, 0.0)], 60), 0.0)
|
||||
|
||||
def test_analyse_uses_nearest_frame(self):
|
||||
times, values = synthetic(72, 4, patches=4, seconds=10)
|
||||
# Right-eye frames 1 ms before each left frame: nearest is that frame.
|
||||
frames = [(t, "left", [values[k][i] for k in range(4)]) for i, t in enumerate(times)]
|
||||
frames += [(t - 0.001, "right", [float(i)] * 4) for i, t in enumerate(times)]
|
||||
seen = {}
|
||||
original = pulse.estimate
|
||||
with patch.object(pulse, "estimate", lambda t, p: seen.update(p) or original(t, p)):
|
||||
pulse.analyse(frames)
|
||||
self.assertEqual(seen["right0"][:5], [0.0, 0.0, 1.0, 1.0, 2.0])
|
||||
|
||||
def test_fft_matches_dft(self):
|
||||
signal = [math.sin(i) + (i % 3) for i in range(16)]
|
||||
fast = pulse.fft(signal)
|
||||
for k in range(16):
|
||||
slow = sum(signal[n] * complex(math.cos(2 * math.pi * k * n / 16), -math.sin(2 * math.pi * k * n / 16))
|
||||
for n in range(16))
|
||||
self.assertAlmostEqual(abs(fast[k] - slow), 0, places=9)
|
||||
|
||||
def test_grid_means(self):
|
||||
# 4 × 4 image in RGB with padding: left half 10, right half 30 (channel 0).
|
||||
width, height, channels, stride = 4, 4, 3, 16
|
||||
pixels = bytearray(stride * height)
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
pixels[y * stride + x * channels] = 10 if x < 2 else 30
|
||||
pixels[y * stride + x * channels + 1] = 255 # other channels ignored
|
||||
self.assertEqual(pulse.grid_means(bytes(pixels), width, height, stride, channels, grid=2),
|
||||
[10, 30, 10, 30])
|
||||
|
||||
def test_analyse_combines_both_eyes(self):
|
||||
times, values = synthetic(80, 16, patches=16)
|
||||
frames = []
|
||||
for i, t in enumerate(times):
|
||||
frames.append((t, "left", [values[k][i] for k in range(16)]))
|
||||
frames.append((t + 0.001, "right", [values[k][i] for k in range(16)]))
|
||||
result = pulse.analyse(frames)
|
||||
self.assertAlmostEqual(result["bpm"], 80, delta=1.5)
|
||||
self.assertEqual(result["usable"] % 2, 0)
|
||||
|
||||
|
||||
class FakeCaptureTool:
|
||||
"""Stands in for `eyetracking --calib`: writes PNG names over a few polls."""
|
||||
def __init__(self, directory, frames=6, fail=False):
|
||||
self.directory, self.frames, self.fail = Path(directory), frames, fail
|
||||
self.polls = 0
|
||||
self.returncode = None
|
||||
self.stdout = None
|
||||
self.announce = None # a different directory to report, if set
|
||||
|
||||
def __call__(self, command, cwd, stdout, stderr):
|
||||
self.command = command
|
||||
self.stdout = stdout
|
||||
if self.fail:
|
||||
stdout.write("Failed to initialize cameras\n")
|
||||
stdout.flush()
|
||||
self.returncode = 1
|
||||
return self
|
||||
# Real output is block-buffered until exit, so nothing is printed here.
|
||||
stdout.flush()
|
||||
self.directory.mkdir()
|
||||
return self
|
||||
|
||||
def poll(self):
|
||||
if self.returncode is not None:
|
||||
return self.returncode
|
||||
if self.polls < self.frames:
|
||||
for eye in ("left", "right"):
|
||||
(self.directory / f"{eye}_{self.polls}.png").write_bytes(b"png")
|
||||
self.polls += 1
|
||||
return None
|
||||
meta = {"frames": [{eye: {"fname": str(self.directory / f"{eye}_{i}.png"), "frameNum": i + 10,
|
||||
"tsMono": 100 + i / 90, "valid": i != 2} for eye in ("left", "right")}
|
||||
for i in range(self.frames)]}
|
||||
(self.directory / "meta.json").write_text(json.dumps(meta))
|
||||
# The real tool's buffered output only appears once it exits.
|
||||
self.stdout.write(f"Writing capture to: {self.announce or self.directory}\nCaptured images\n")
|
||||
self.stdout.flush()
|
||||
self.returncode = 0
|
||||
return 0
|
||||
|
||||
def terminate(self):
|
||||
raise AssertionError("the capture tool must never be signalled")
|
||||
|
||||
kill = terminate
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return self.returncode
|
||||
|
||||
|
||||
class WornCheck(unittest.TestCase):
|
||||
def sensor(self, name, raw):
|
||||
root = Path(self.enterContext(tempfile.TemporaryDirectory()))
|
||||
device = root / "iio:device2"
|
||||
device.mkdir()
|
||||
(device / "name").write_text(name + "\n")
|
||||
(device / "in_proximity_raw").write_text(raw + "\n")
|
||||
return root
|
||||
|
||||
def test_reads_the_proximity_sensor(self):
|
||||
self.assertEqual(pulse.proximity(self.sensor("vcnl4000", "3.250000000")), 3.25)
|
||||
|
||||
def test_unreadable_sensor_does_not_block(self):
|
||||
self.assertIsNone(pulse.proximity(self.sensor("other", "9")))
|
||||
self.assertIsNone(pulse.proximity(self.sensor("vcnl4000", "junk")))
|
||||
self.assertIsNone(pulse.proximity(Path("/nonexistent")))
|
||||
self.assertTrue(pulse.worn(None))
|
||||
|
||||
def test_low_reading_means_unworn(self):
|
||||
self.assertFalse(pulse.worn(3.1))
|
||||
self.assertTrue(pulse.worn(pulse.WORN_MIN))
|
||||
|
||||
|
||||
class CaptureLifecycle(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp())
|
||||
self.directory = self.root / "etcalib_test"
|
||||
(self.root / "etcalib_older").mkdir() # an earlier capture is not ours
|
||||
self.loaded = []
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.root, ignore_errors=True)
|
||||
|
||||
def loader(self, path):
|
||||
self.loaded.append(Path(path).name)
|
||||
self.assertTrue(Path(path).exists())
|
||||
return [float(len(self.loaded))]
|
||||
|
||||
def capture(self, tool):
|
||||
class TestCapture(pulse.Capture):
|
||||
# A temporary directory stands in for /tmp/etcalib_*.
|
||||
PREFIX = str(self.root / "etcalib_")
|
||||
capture = TestCapture(20, runner=tool, loader=self.loader, workers=0)
|
||||
with patch.object(pulse.time, "sleep", lambda s: None):
|
||||
return capture, capture.run()
|
||||
|
||||
def test_reduces_deletes_and_joins_metadata(self):
|
||||
tool = FakeCaptureTool(self.directory)
|
||||
capture, frames = self.capture(tool)
|
||||
self.assertEqual(sorted(self.loaded), sorted(f"{e}_{i}.png" for e in ("left", "right") for i in range(6)))
|
||||
self.assertFalse(self.directory.exists()) # images and metadata removed
|
||||
self.assertTrue((self.root / "etcalib_older").exists())
|
||||
self.assertEqual(len(frames), 10) # frame 2 invalid in both eyes
|
||||
self.assertEqual(tool.command[-2:], ["--calib", "20"])
|
||||
self.assertTrue(all(isinstance(t, float) and eye in ("left", "right") for t, eye, _ in frames))
|
||||
|
||||
def test_camera_failure(self):
|
||||
tool = FakeCaptureTool(self.directory, fail=True)
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
self.capture(tool)
|
||||
self.assertIn("cameras unavailable", str(raised.exception))
|
||||
|
||||
def test_backlog_abandons_capture_and_removes_images(self):
|
||||
class Stalled(FakeCaptureTool):
|
||||
def poll(self):
|
||||
if self.polls > 8:
|
||||
self.returncode = 0 # the bounded run ends by itself
|
||||
return 0
|
||||
for i in range(20):
|
||||
for eye in ("left", "right"):
|
||||
(self.directory / f"{eye}_{self.polls * 20 + i}.png").write_bytes(b"png")
|
||||
self.polls += 1
|
||||
return None
|
||||
tool = Stalled(self.directory)
|
||||
class TestCapture(pulse.Capture):
|
||||
PREFIX = str(self.root / "etcalib_")
|
||||
MAX_BACKLOG = 50
|
||||
capture = TestCapture(20, runner=tool, loader=self.loader, workers=0)
|
||||
capture.reduce = lambda final=False, original=capture.reduce: (
|
||||
original(final) if tool.polls > 3 else None) # reduction stalls
|
||||
with patch.object(pulse.time, "sleep", lambda s: None), self.assertRaises(RuntimeError) as raised:
|
||||
capture.run()
|
||||
self.assertIn("fell behind", str(raised.exception))
|
||||
self.assertEqual(tool.returncode, 0) # left to end by itself, never signalled
|
||||
self.assertFalse(self.directory.exists()) # no image left behind
|
||||
|
||||
def test_second_capture_directory_stops_and_removes_both(self):
|
||||
foreign = self.root / "etcalib_foreign"
|
||||
class Racing(FakeCaptureTool):
|
||||
def poll(self):
|
||||
if self.polls == 2:
|
||||
foreign.mkdir()
|
||||
(foreign / "left_0.png").write_bytes(b"png")
|
||||
return super().poll()
|
||||
tool = Racing(self.directory)
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
self.capture(tool)
|
||||
self.assertIn("another eye-camera capture", str(raised.exception))
|
||||
self.assertFalse(self.directory.exists())
|
||||
self.assertFalse(foreign.exists()) # no eye image outlives the run
|
||||
self.assertTrue((self.root / "etcalib_older").exists())
|
||||
|
||||
def test_images_elsewhere_are_not_used_but_are_removed(self):
|
||||
tool = FakeCaptureTool(self.directory)
|
||||
tool.announce = self.root / "etcalib_reported"
|
||||
tool.announce.mkdir()
|
||||
(tool.announce / "left_0.png").write_bytes(b"png")
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
self.capture(tool)
|
||||
self.assertIn("somewhere else", str(raised.exception))
|
||||
self.assertFalse(tool.announce.exists())
|
||||
self.assertFalse(self.directory.exists())
|
||||
|
||||
def test_interrupt_during_cleanup_still_removes_images(self):
|
||||
class Stubborn(FakeCaptureTool):
|
||||
interrupts = 0
|
||||
def poll(self):
|
||||
if self.polls == 2 and self.interrupts < 2:
|
||||
self.interrupts += 1
|
||||
raise KeyboardInterrupt # Ctrl-C mid-capture, and again while waiting
|
||||
return super().poll()
|
||||
tool = Stubborn(self.directory)
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
self.capture(tool)
|
||||
self.assertEqual(tool.returncode, 0) # still let finish, never signalled
|
||||
self.assertFalse(self.directory.exists())
|
||||
|
||||
def test_overrunning_tool_is_killed_as_a_last_resort(self):
|
||||
class Hung(FakeCaptureTool):
|
||||
killed = False
|
||||
def poll(self):
|
||||
(self.directory / "left_0.png").write_bytes(b"png")
|
||||
return None if not self.killed else -9
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
tool = Hung(self.directory)
|
||||
clock = iter(range(0, 10000, 20))
|
||||
with patch.object(pulse.time, "monotonic", lambda: next(clock)), \
|
||||
self.assertRaises(RuntimeError) as raised:
|
||||
self.capture(tool)
|
||||
self.assertIn("may need a reboot", str(raised.exception))
|
||||
self.assertTrue(tool.killed)
|
||||
self.assertFalse(self.directory.exists())
|
||||
|
||||
def test_cleanup_tries_every_directory(self):
|
||||
capture = pulse.Capture(20)
|
||||
capture.PREFIX = str(self.root / "etcalib_")
|
||||
first, second = self.root / "etcalib_a", self.root / "etcalib_b"
|
||||
for directory in (first, second):
|
||||
directory.mkdir()
|
||||
(directory / "left_0.png").write_bytes(b"png")
|
||||
capture.new = {first, second}
|
||||
real = pulse.shutil.rmtree
|
||||
def flaky(path):
|
||||
if Path(path) == first:
|
||||
raise OSError("busy")
|
||||
real(path)
|
||||
with patch.object(pulse.shutil, "rmtree", flaky), self.assertRaises(RuntimeError) as raised:
|
||||
capture.remove()
|
||||
self.assertIn("etcalib_a", str(raised.exception))
|
||||
self.assertFalse(second.exists())
|
||||
|
||||
def test_removes_a_directory_seen_only_at_the_end(self):
|
||||
capture = pulse.Capture(20)
|
||||
capture.PREFIX = str(self.root / "etcalib_")
|
||||
capture.before = capture.candidates()
|
||||
late = self.root / "etcalib_late"
|
||||
late.mkdir()
|
||||
(late / "left_0.png").write_bytes(b"png")
|
||||
capture.remove()
|
||||
self.assertFalse(late.exists())
|
||||
self.assertTrue((self.root / "etcalib_older").exists())
|
||||
|
||||
def test_never_adopts_directories_without_a_baseline(self):
|
||||
capture = pulse.Capture(20)
|
||||
capture.PREFIX = str(self.root / "etcalib_")
|
||||
capture.remove()
|
||||
self.assertTrue((self.root / "etcalib_older").exists())
|
||||
|
||||
def test_only_removes_capture_directories(self):
|
||||
capture = pulse.Capture(20)
|
||||
capture.directory = self.root
|
||||
capture.remove()
|
||||
self.assertTrue(self.root.exists())
|
||||
|
||||
|
||||
class HeartCheck(unittest.TestCase):
|
||||
def write(self, name, text):
|
||||
path = Path(self.enterContext(tempfile.TemporaryDirectory())) / name
|
||||
path.write_text(text)
|
||||
return path
|
||||
|
||||
def test_osc_round_trip(self):
|
||||
tracking = load("tracking", "frame/tracking/tracking.py")
|
||||
self.assertEqual(check.parse_osc(tracking.osc_message("/avatar/parameters/HeartRate", [72])),
|
||||
("/avatar/parameters/HeartRate", [72]))
|
||||
address, values = check.parse_osc(tracking.osc_message("/x", [1.5, -2.0]))
|
||||
self.assertEqual((address, values), ("/x", [1.5, -2.0]))
|
||||
with self.assertRaises(ValueError):
|
||||
check.parse_osc(b"/x\0\0,s\0\0abc\0")
|
||||
|
||||
def test_listen_records_readings(self):
|
||||
tracking = load("tracking", "frame/tracking/tracking.py")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
out = Path(directory) / "ours.csv"
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
import threading
|
||||
def send():
|
||||
import time
|
||||
time.sleep(0.3)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sender:
|
||||
sender.sendto(tracking.osc_message(check.ADDRESS, [72]), ("127.0.0.1", port))
|
||||
sender.sendto(tracking.osc_message("/other", [1]), ("127.0.0.1", port))
|
||||
threading.Thread(target=send).start()
|
||||
shown = io.StringIO()
|
||||
count = check.listen(port, check.ADDRESS, str(out), 1.5, stream=shown)
|
||||
self.assertEqual(count, 1)
|
||||
self.assertIn("72 bpm", shown.getvalue())
|
||||
self.assertEqual(out.read_text().splitlines()[1].split(",")[1], "72")
|
||||
self.assertEqual(out.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_compare_finds_lag_and_contact_loss(self):
|
||||
reference = [(1000.0 + i, 60 + i, 6) for i in range(40)] + [(1040.0 + i, 150, 4) for i in range(5)]
|
||||
ours = [(1002.0 + i, 60 + i) for i in range(40)] # 2 s late, nothing during contact loss
|
||||
ref = self.write("ref.csv", "unix_seconds,bpm,flags\n" + "".join(f"{t},{b},{f}\n" for t, b, f in reference))
|
||||
mine = self.write("ours.csv", "unix_seconds,bpm\n" + "".join(f"{t},{b}\n" for t, b in ours))
|
||||
result = check.compare(check.read_csv(mine), check.read_any(ref))
|
||||
self.assertEqual(result["lag_seconds"], 2.0)
|
||||
self.assertEqual(result["mean_abs_error"], 0)
|
||||
self.assertEqual(result["shown_during_no_contact"], 0)
|
||||
self.assertTrue(check.report(result, 5, stream=io.StringIO()))
|
||||
# A stale reading sent during contact loss fails the check.
|
||||
stale = check.read_csv(mine) + [(1043.0, 99)]
|
||||
result = check.compare(stale, check.read_any(ref))
|
||||
self.assertEqual(result["shown_during_no_contact"], 1)
|
||||
self.assertFalse(check.report(result, 5, stream=io.StringIO()))
|
||||
|
||||
def test_compare_against_apple_health_export(self):
|
||||
records = "".join(
|
||||
f'<Record type="HKQuantityTypeIdentifierHeartRate" unit="count/min" '
|
||||
f'startDate="2026-09-29 12:00:{s:02d} +1000" endDate="2026-09-29 12:00:{s:02d} +1000" value="{70 + s % 3}"/>'
|
||||
for s in range(0, 60, 5))
|
||||
other = '<Record type="HKQuantityTypeIdentifierStepCount" startDate="2026-09-29 12:00:00 +1000" value="9"/>'
|
||||
xml = f'<?xml version="1.0"?><HealthData>{other}{records}</HealthData>'
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
archive = Path(directory) / "export.zip"
|
||||
with zipfile.ZipFile(archive, "w") as z:
|
||||
z.writestr("apple_health_export/export.xml", xml)
|
||||
start = check.parse_time("2026-09-29 12:00:00 +1000")
|
||||
samples = check.read_any(archive, start - 60, start + 120)
|
||||
self.assertEqual(len(samples), 12)
|
||||
self.assertEqual(samples[0], (start, 70))
|
||||
ours = [(start + i + 1.0, 71) for i in range(60)]
|
||||
result = check.compare(ours, samples)
|
||||
self.assertLessEqual(result["mean_abs_error"], 1)
|
||||
|
||||
def test_unusual_flags_and_missing_export(self):
|
||||
path = self.write("ref.csv", "time,bpm,flags\n1000,70,6.0\n1001,71,yes\n1002,72,4\n")
|
||||
self.assertEqual(check.read_csv(path), [(1000.0, 70), (1001.0, 71), (1002.0, None)])
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
archive = Path(directory) / "other.zip"
|
||||
with zipfile.ZipFile(archive, "w") as z:
|
||||
z.writestr("notes.txt", "x")
|
||||
with self.assertRaises(ValueError):
|
||||
check.read_any(archive, 0, 1)
|
||||
|
||||
def test_compare_reports_bad_input_without_traceback(self):
|
||||
good = self.write("ref.csv", "1000,70\n")
|
||||
shown = io.StringIO()
|
||||
with patch("sys.stdout", shown):
|
||||
self.assertEqual(check.main(["compare", str(good.parent / "missing.csv"), str(good)]), 1)
|
||||
self.assertEqual(check.main(["compare", str(self.write("ours.csv", "1000,inf\n1001,70\n")),
|
||||
str(self.write("bad.xml", "<not closed"))]), 1)
|
||||
self.assertEqual(shown.getvalue().count("Could not compare"), 2)
|
||||
|
||||
def test_health_records_with_non_finite_values_are_skipped(self):
|
||||
record = '<Record type="HKQuantityTypeIdentifierHeartRate" startDate="2026-09-29 12:00:00 +1000" value="%s"/>'
|
||||
path = self.write("export.xml", "<HealthData>" + record % "inf" + record % "72" + "</HealthData>")
|
||||
start = check.parse_time("2026-09-29 12:00:00 +1000")
|
||||
self.assertEqual(check.read_health(path, start - 1, start + 1), [(start, 72)])
|
||||
|
||||
def test_time_formats(self):
|
||||
self.assertEqual(check.parse_time("1700000000.5"), 1700000000.5)
|
||||
self.assertEqual(check.parse_time("2023-11-14T22:13:20Z"), 1700000000)
|
||||
self.assertEqual(check.parse_time("2023-11-15 08:13:20 +1000"), 1700000000)
|
||||
with self.assertRaises(ValueError):
|
||||
check.parse_time("yesterday")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,566 @@
|
||||
"""Repositories and sources behind a login: F-Droid basic auth and GitHub tokens.
|
||||
|
||||
A fake HTTPS layer stands in for the network (urllib's real redirect, error and
|
||||
request processing still run), so these tests see every request and its headers.
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import base64
|
||||
import contextlib
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
import urllib.request
|
||||
import urllib.response
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
from apk_sources import SourceAuthError, SourceError, SourceLimited, _credentials, _images, _web, fdroid, github, search
|
||||
import server
|
||||
|
||||
FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
|
||||
PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
|
||||
REPO = 'https://repo.example/fdroid/repo/'
|
||||
USER, PASSWORD = 'alice', 's3cr@t-pässword'
|
||||
TOKEN = 'ghp_frameControlTestToken0123456789'
|
||||
SECRETS = (PASSWORD, TOKEN, base64.b64encode((USER + ':' + PASSWORD).encode()).decode())
|
||||
REAL_BUILD_OPENER = urllib.request.build_opener
|
||||
|
||||
|
||||
def basic(user, password):
|
||||
return 'Basic ' + base64.b64encode((user + ':' + password).encode()).decode()
|
||||
|
||||
|
||||
class FakeHTTPS(urllib.request.HTTPSHandler):
|
||||
"""Answers https requests from route(url, authorization) -> (status, headers, body)."""
|
||||
|
||||
def __init__(self, route):
|
||||
super().__init__()
|
||||
self.route, self.seen = route, []
|
||||
|
||||
def https_open(self, req):
|
||||
auth = req.get_header('Authorization')
|
||||
self.seen.append((req.full_url, auth))
|
||||
status, headers, body = self.route(req.full_url, auth)
|
||||
message = http.client.HTTPMessage()
|
||||
for key, value in headers.items():
|
||||
message[key] = value
|
||||
response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, status)
|
||||
response.msg = 'fake'
|
||||
return response
|
||||
|
||||
|
||||
class Base(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name)
|
||||
for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)),
|
||||
('cache_dir', lambda *p: self.root.joinpath('cache', *p))]:
|
||||
mock = patch.object(fdroid.frame_host, name, value)
|
||||
mock.start()
|
||||
self.addCleanup(mock.stop)
|
||||
self.web = FakeHTTPS(self.route)
|
||||
mock = patch('urllib.request.build_opener', lambda *handlers: REAL_BUILD_OPENER(*handlers, self.web))
|
||||
mock.start()
|
||||
self.addCleanup(mock.stop)
|
||||
mock = patch.dict(os.environ, {github.ENV: ''})
|
||||
mock.start()
|
||||
self.addCleanup(mock.stop)
|
||||
for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing, github._rejected):
|
||||
state.clear()
|
||||
self.addCleanup(state.clear)
|
||||
self.stderr = io.StringIO()
|
||||
mock = patch('sys.stderr', self.stderr)
|
||||
mock.start()
|
||||
self.addCleanup(mock.stop)
|
||||
|
||||
def route(self, url, auth):
|
||||
raise AssertionError('unexpected request ' + url)
|
||||
|
||||
def assertNoSecrets(self, *texts):
|
||||
for text in texts:
|
||||
for secret in SECRETS + (USER + ':',):
|
||||
self.assertNotIn(secret, text)
|
||||
|
||||
def disk(self):
|
||||
"""Everything saved under the data and cache folders except the credentials file."""
|
||||
out = []
|
||||
for path in self.root.rglob('*'):
|
||||
if path.is_file() and path.name != 'source-credentials.json':
|
||||
out.append(path.read_bytes().decode('utf-8', 'replace'))
|
||||
return '\n'.join(out)
|
||||
|
||||
def credentials_file(self):
|
||||
return self.root / 'data' / 'source-credentials.json'
|
||||
|
||||
|
||||
class Repository(Base):
|
||||
"""A repository at REPO that wants USER and PASSWORD."""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.login = basic(USER, PASSWORD)
|
||||
self.redirect = None # file name -> absolute URL it redirects to
|
||||
self.forbidden = 401
|
||||
|
||||
def serve(self, name):
|
||||
return (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
|
||||
|
||||
def route(self, url, auth):
|
||||
for mirror in ('https://mirror.example/fdroid/repo/', 'https://repo.example/elsewhere/'):
|
||||
if url.startswith(mirror): # serves anyone; a login must never get here unless same-origin
|
||||
return 200, {}, self.serve(url[len(mirror):])
|
||||
self.assertTrue(url.startswith(REPO), url)
|
||||
if auth != self.login:
|
||||
return self.forbidden, {'WWW-Authenticate': 'Basic realm="repo"'}, b'no'
|
||||
name = url[len(REPO):]
|
||||
if self.redirect and name in self.redirect:
|
||||
return 302, {'Location': self.redirect[name]}, b''
|
||||
return 200, {}, self.serve(name)
|
||||
|
||||
def add(self, **kw):
|
||||
return fdroid.add_repo('https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo', PIN, **kw)
|
||||
|
||||
|
||||
class RepositoryLogin(Repository):
|
||||
def test_add_search_download_with_login_in_the_url(self):
|
||||
source = self.add()
|
||||
self.assertEqual(source['url'], REPO)
|
||||
self.assertEqual(source['fingerprint'], PIN)
|
||||
saved = fdroid.user_repos()[0]
|
||||
self.assertEqual(saved['url'], REPO)
|
||||
self.assertNotIn('login', saved)
|
||||
self.assertEqual(len(fdroid.search(source, 'example offline')), 1)
|
||||
downloaded = fdroid.download(source, 'org.example.app', 1)
|
||||
self.assertTrue(downloaded['verified'])
|
||||
self.assertTrue(self.web.seen)
|
||||
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
|
||||
self.assertTrue(all('@' not in url for url, auth in self.web.seen)) # never in the URL
|
||||
self.assertTrue(next(s for s in fdroid.sources() if s['id'] == source['id'])['login'])
|
||||
self.assertNoSecrets(json.dumps(source), json.dumps(fdroid.sources()), self.disk(), self.stderr.getvalue())
|
||||
# The settings file holds no part of the login; the credentials file holds it, privately.
|
||||
settings = (self.root / 'data' / 'apk-repos.json').read_text()
|
||||
self.assertNotIn(USER, settings)
|
||||
self.assertTrue(self.credentials_file().is_file())
|
||||
if os.name != 'nt':
|
||||
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
|
||||
|
||||
def test_login_given_apart_and_saved_login_reused_on_refresh(self):
|
||||
source = fdroid.add_repo(REPO, PIN, username=USER, password=PASSWORD)
|
||||
self.assertEqual(source['url'], REPO)
|
||||
self.web.seen.clear()
|
||||
fdroid._load(fdroid.user_repos()[0], force=True) # as a background refresh would
|
||||
self.assertTrue(self.web.seen)
|
||||
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
|
||||
fdroid.add_repo(REPO) # re-adding without a login keeps the saved one
|
||||
self.assertTrue(_credentials.has(source['id']))
|
||||
|
||||
def test_wrong_or_missing_login_saves_nothing(self):
|
||||
for kw, message in [({'username': USER, 'password': 'wrong'}, 'check the username and password'),
|
||||
({}, 'needs a username and password')]:
|
||||
with self.subTest(kw=kw):
|
||||
with self.assertRaisesRegex(SourceAuthError, message) as caught:
|
||||
fdroid.add_repo(REPO, PIN, **kw)
|
||||
self.assertNoSecrets(str(caught.exception), repr(caught.exception))
|
||||
self.assertNotIn('wrong', str(caught.exception))
|
||||
self.assertEqual(fdroid.user_repos(), [])
|
||||
self.assertFalse(self.credentials_file().exists())
|
||||
self.assertFalse((self.root / 'data' / 'apk-repo-state.json').exists())
|
||||
self.assertEqual(_web._limited, {}) # an auth failure isn't a rate limit
|
||||
|
||||
def test_403_with_a_login_is_a_login_error_not_a_rate_limit(self):
|
||||
self.forbidden = 403
|
||||
with self.assertRaisesRegex(SourceAuthError, 'check the username and password'):
|
||||
fdroid.add_repo(REPO, PIN, username=USER, password='wrong')
|
||||
self.assertEqual(_web._limited, {})
|
||||
self.assertEqual(fdroid.user_repos(), [])
|
||||
|
||||
def test_signature_checks_still_apply(self):
|
||||
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
|
||||
fdroid.add_repo(REPO, '0' * 64, username=USER, password=PASSWORD)
|
||||
self.assertEqual(fdroid.user_repos(), [])
|
||||
self.assertFalse(self.credentials_file().exists())
|
||||
|
||||
def test_cross_origin_redirect_never_gets_the_login(self):
|
||||
self.redirect = {'entry.jar': 'https://mirror.example/fdroid/repo/entry.jar',
|
||||
'index-v2.json': 'https://repo.example/elsewhere/index-v2.json'}
|
||||
source = self.add()
|
||||
self.assertEqual(source['fingerprint'], PIN) # the mirror's copy still had to verify
|
||||
mirror = [auth for url, auth in self.web.seen if url.startswith('https://mirror.example/')]
|
||||
same_origin = [auth for url, auth in self.web.seen if url.startswith('https://repo.example/elsewhere/')]
|
||||
self.assertEqual(mirror, [None])
|
||||
self.assertEqual(same_origin, [None]) # same origin but outside the repository: no login either
|
||||
|
||||
def test_traversal_names_are_refused_and_never_get_the_login(self):
|
||||
for name in ('%252e%252e/%252e%252e/evil/collect.apk', '..;/..;/evil/collect.apk', 'a%2f..%2f..%2fevil.apk',
|
||||
'a%5c..%5cevil.apk', '%2e%2e/evil.apk', 'x;y.apk', '100%25.apk'):
|
||||
with self.subTest(name=name), self.assertRaisesRegex(SourceError, 'unsafe|outside'):
|
||||
fdroid._child(REPO, name)
|
||||
self.assertEqual(fdroid._child(REPO, '/app/en-US/phoneScreenshots/#0 a.png'),
|
||||
REPO + 'app/en-US/phoneScreenshots/%230%20a.png')
|
||||
# Even if such a URL reached the downloader directly, the login stays home.
|
||||
login = _credentials.basic(REPO, USER, PASSWORD)
|
||||
for url in (REPO + '%252e%252e/%252e%252e/evil/collect.apk', REPO + '..;/..;/evil/collect.apk'):
|
||||
with self.subTest(url=url), tempfile.TemporaryDirectory() as tmp:
|
||||
self.web.seen.clear()
|
||||
with contextlib.suppress(SourceError): # the repository answers 401 without the login
|
||||
fdroid._fetch(url, Path(tmp) / 'out', 1 << 20, auth=login)
|
||||
self.assertEqual(self.web.seen, [(url, None)])
|
||||
|
||||
def test_same_origin_redirect_outside_the_repository_gets_no_login(self):
|
||||
self.redirect = {'example1.apk': 'https://repo.example/elsewhere/example1.apk'}
|
||||
source = self.add()
|
||||
self.web.seen.clear()
|
||||
self.assertTrue(fdroid.download(source, 'org.example.app', 1)['verified'])
|
||||
self.assertEqual(self.web.seen, [(REPO + 'example1.apk', self.login),
|
||||
('https://repo.example/elsewhere/example1.apk', None)])
|
||||
|
||||
def test_other_ports_and_http_never_get_the_login(self):
|
||||
login = _credentials.basic(REPO, USER, PASSWORD)
|
||||
for url in ('https://repo.example:8443/fdroid/repo/x', 'http://repo.example/fdroid/repo/x',
|
||||
'https://other.example/fdroid/repo/x', 'https://repo.example.evil/x'):
|
||||
self.assertFalse(login.matches(url), url)
|
||||
self.assertTrue(login.matches('https://REPO.example:443/anything'))
|
||||
self.assertNoSecrets(repr(login))
|
||||
handler = _credentials.Handler(login)
|
||||
req = urllib.request.Request('https://other.example/x', headers={'Authorization': 'Basic stale'})
|
||||
self.assertIsNone(handler.https_request(req).get_header('Authorization'))
|
||||
|
||||
def test_http_and_bad_logins_are_refused_before_any_request(self):
|
||||
for url, kw in [('http://alice:pw@repo.example/fdroid/repo', {}),
|
||||
('http://repo.example/fdroid/repo', {'username': USER, 'password': PASSWORD}),
|
||||
(REPO, {'username': 'a:b', 'password': PASSWORD}),
|
||||
(REPO, {'username': USER, 'password': 'x\r\nX-Evil: 1'}),
|
||||
(REPO, {'password': PASSWORD}),
|
||||
('https://alice:pw@repo.example/fdroid/repo', {'username': 'bob'})]:
|
||||
with self.subTest(url=url, kw=kw), self.assertRaises(SourceError) as caught:
|
||||
fdroid.add_repo(url, PIN, **kw)
|
||||
self.assertNotIn('pw', str(caught.exception).replace('password', ''))
|
||||
self.assertNoSecrets(str(caught.exception))
|
||||
self.assertEqual(self.web.seen, [])
|
||||
|
||||
def test_remove_deletes_the_login(self):
|
||||
source = self.add()
|
||||
other = _credentials.bearer('https://api.github.com/', TOKEN)
|
||||
_credentials.put('github', other)
|
||||
fdroid.remove_repo(source['id'])
|
||||
self.assertFalse(_credentials.has(source['id']))
|
||||
self.assertNotIn(PASSWORD, self.credentials_file().read_text())
|
||||
_credentials.remove('github')
|
||||
self.assertFalse(self.credentials_file().exists()) # nothing left: the file goes
|
||||
|
||||
def test_cli_reads_the_password_from_stdin(self):
|
||||
out = io.StringIO()
|
||||
argv = ['fdroid.py', 'add', REPO, '--fingerprint', PIN, '--username', USER, '--password-stdin']
|
||||
with patch.object(sys, 'argv', argv), patch.object(sys, 'stdin', io.StringIO(PASSWORD + '\n')), \
|
||||
contextlib.redirect_stdout(out):
|
||||
fdroid.main()
|
||||
self.assertEqual(json.loads(out.getvalue())['url'], REPO)
|
||||
self.assertNoSecrets(out.getvalue())
|
||||
self.assertTrue(_credentials.has(fdroid.user_repos()[0]['id']))
|
||||
|
||||
def test_artwork_from_the_repository_gets_the_login_and_only_there(self):
|
||||
source = self.add()
|
||||
self.assertEqual(_credentials.for_url(REPO + 'icons/a.png').header(), self.login)
|
||||
self.assertTrue(_credentials.has(source['id']))
|
||||
self.assertIsNone(_credentials.for_url('https://repo.example/other/a.png')) # outside the repository
|
||||
self.assertIsNone(_credentials.for_url('https://cdn.example/fdroid/repo/a.png'))
|
||||
|
||||
|
||||
class GitHubToken(Base):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.accept = {TOKEN}
|
||||
self.releases = json.dumps([]).encode()
|
||||
self.anonymous_limited = False
|
||||
|
||||
def route(self, url, auth):
|
||||
if url.startswith('https://api.github.com/'):
|
||||
if auth is not None and auth not in {'Bearer ' + t for t in self.accept}:
|
||||
return 401, {}, b'{"message": "Bad credentials"}'
|
||||
if auth is None and self.anonymous_limited:
|
||||
return 403, {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(time.time()) + 1800)}, b'{}'
|
||||
if url.endswith('/redirected'):
|
||||
return 302, {'Location': 'https://github.com/landed'}, b''
|
||||
return 200, {}, self.releases
|
||||
if url == 'https://github.com/landed':
|
||||
return 200, {}, b'{"ok": true}'
|
||||
raise AssertionError('unexpected request ' + url)
|
||||
|
||||
def test_saved_token_goes_only_to_the_api(self):
|
||||
github.set_token(TOKEN)
|
||||
self.assertEqual(github._api('/repos/a/b/releases'), [])
|
||||
self.assertEqual(self.web.seen, [('https://api.github.com/repos/a/b/releases', 'Bearer ' + TOKEN)])
|
||||
auth = github._credential(github._token()[0])
|
||||
self.assertEqual(_web.read('https://api.github.com/redirected', ('api.github.com', 'github.com'), auth=auth),
|
||||
b'{"ok": true}')
|
||||
self.assertEqual(self.web.seen[-2:], [('https://api.github.com/redirected', 'Bearer ' + TOKEN),
|
||||
('https://github.com/landed', None)]) # redirect: no token
|
||||
status = github.sources()[0]['token']
|
||||
self.assertEqual(status, {'set': True, 'from': 'saved', 'rejected': False})
|
||||
self.assertNoSecrets(json.dumps(github.sources()), self.disk(), self.stderr.getvalue())
|
||||
if os.name != 'nt':
|
||||
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
|
||||
|
||||
def test_rejected_token_retries_once_without_it(self):
|
||||
github.set_token(TOKEN)
|
||||
self.accept = set()
|
||||
self.assertEqual(github._api('/repos/a/b/releases'), [])
|
||||
self.assertEqual([auth for _, auth in self.web.seen], ['Bearer ' + TOKEN, None])
|
||||
self.assertTrue(github.token_status()['rejected'])
|
||||
self.assertIn('rejected', self.stderr.getvalue())
|
||||
self.assertNoSecrets(self.stderr.getvalue())
|
||||
self.web.seen.clear()
|
||||
github._api('/repos/a/b/other')
|
||||
self.assertEqual([auth for _, auth in self.web.seen], [None]) # not tried again this run
|
||||
github.set_token(TOKEN) # saving it again gives it a fresh chance
|
||||
self.assertFalse(github.token_status()['rejected'])
|
||||
|
||||
def test_environment_variable_wins(self):
|
||||
github.set_token(TOKEN)
|
||||
env = 'ghp_fromTheEnvironment'
|
||||
self.accept = {env}
|
||||
with patch.dict(os.environ, {github.ENV: env}):
|
||||
github._api('/repos/a/b/releases')
|
||||
self.assertEqual(self.web.seen[-1][1], 'Bearer ' + env)
|
||||
self.assertEqual(github.token_status(), {'set': True, 'from': 'env', 'rejected': False})
|
||||
self.assertEqual(github.token_status()['from'], 'saved')
|
||||
|
||||
def test_clear_removes_it_from_disk(self):
|
||||
github.set_token(TOKEN)
|
||||
github.clear_token()
|
||||
self.assertFalse(self.credentials_file().exists())
|
||||
self.assertEqual(github.token_status(), {'set': False, 'from': None, 'rejected': False})
|
||||
github._api('/repos/a/b/releases')
|
||||
self.assertEqual(self.web.seen[-1][1], None)
|
||||
|
||||
def test_saving_a_token_escapes_the_anonymous_rate_limit(self):
|
||||
self.anonymous_limited = True
|
||||
with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'):
|
||||
github._api('/repos/a/b/releases')
|
||||
self.assertEqual(self.web.seen[-1][1], None)
|
||||
github.set_token(TOKEN)
|
||||
self.assertEqual(github._api('/repos/a/b/other'), []) # tried, with the token's own quota
|
||||
self.assertEqual(self.web.seen[-1], ('https://api.github.com/repos/a/b/other', 'Bearer ' + TOKEN))
|
||||
github.clear_token()
|
||||
count = len(self.web.seen)
|
||||
with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'):
|
||||
github._api('/repos/a/b/third')
|
||||
self.assertEqual(len(self.web.seen), count) # anonymous is still backing off: no request
|
||||
|
||||
def test_bad_tokens_are_refused(self):
|
||||
for token in ('', ' ', 'two words', 'x\nAuthorization: y'):
|
||||
with self.subTest(token=token), self.assertRaises(SourceError):
|
||||
github.set_token(token)
|
||||
self.assertFalse(self.credentials_file().exists())
|
||||
|
||||
|
||||
PNG = b'\x89PNG\r\n\x1a\nfixture'
|
||||
PRIVATE = 'https://repo.example/private/repo/'
|
||||
TRAVERSALS = [PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', PRIVATE + '%2E%2E/x.png', PRIVATE + '../../evil/x.png',
|
||||
PRIVATE + './icons/a.png', PRIVATE + '..%2f..%2fevil/x.png', PRIVATE + 'a%5c..%5c..%5cevil.png',
|
||||
PRIVATE + 'a;b/../../evil.png', PRIVATE + '%252e%252e/%252e%252e/evil.png',
|
||||
'https://repo.example/private/repository/x.png', 'https://repo.example/private/repo%2fx.png']
|
||||
|
||||
|
||||
class ArtworkScope(Base):
|
||||
"""The artwork proxy: actual outgoing headers, through _images.get's own HTTP code."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.sent, self.redirects = [], {}
|
||||
_credentials.put('fdroid-user-private', _credentials.basic(PRIVATE, USER, PASSWORD))
|
||||
test = self
|
||||
|
||||
class Response:
|
||||
def __init__(self, status, location=''):
|
||||
self.status, self.location, self.body = status, location, PNG if status == 200 else b''
|
||||
|
||||
def getheader(self, name, default=''):
|
||||
return self.location if name == 'Location' else default
|
||||
|
||||
def read1(self, n):
|
||||
chunk, self.body = self.body[:n], self.body[n:]
|
||||
return chunk
|
||||
|
||||
class Connection:
|
||||
def __init__(self, host, port, timeout=None):
|
||||
self.host, self.port = host, port
|
||||
|
||||
def request(self, method, path, headers):
|
||||
url = 'https://%s%s' % (self.host, path)
|
||||
test.sent.append((url, headers.get('Authorization')))
|
||||
self.url = url
|
||||
|
||||
def getresponse(self):
|
||||
if self.url in test.redirects:
|
||||
return Response(302, test.redirects[self.url])
|
||||
return Response(200)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
# A public address, no real socket or TLS, and Connection in place of http.client's.
|
||||
for target, name, value in [(_images, '_resolve', lambda host, port, timeout: [(2, 1, 6, '', ('93.184.216.34', port))]),
|
||||
(_images.socket, 'create_connection', lambda *a, **k: Mock()),
|
||||
(_images.ssl, 'create_default_context', lambda: Mock()),
|
||||
(_images.http.client, 'HTTPConnection', Connection)]:
|
||||
mock = patch.object(target, name, value)
|
||||
mock.start()
|
||||
self.addCleanup(mock.stop)
|
||||
|
||||
def test_artwork_in_the_repository_gets_the_login(self):
|
||||
self.assertEqual(_images.get(PRIVATE + 'icons/a%20b.png'), PNG)
|
||||
self.assertEqual(self.sent, [(PRIVATE + 'icons/a%20b.png', basic(USER, PASSWORD))])
|
||||
|
||||
def test_paths_a_server_could_normalise_outside_the_repository_get_nothing(self):
|
||||
for url in TRAVERSALS:
|
||||
with self.subTest(url=url):
|
||||
self.sent.clear()
|
||||
self.assertEqual(_images.get(url), PNG)
|
||||
self.assertEqual(self.sent, [(url, None)])
|
||||
self.assertIsNone(_credentials.for_url(url))
|
||||
|
||||
def test_redirect_to_a_traversal_gets_nothing(self):
|
||||
start = PRIVATE + 'icons/a.png'
|
||||
self.redirects[start] = PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'
|
||||
self.assertEqual(_images.get(start), PNG)
|
||||
self.assertEqual(self.sent, [(start, basic(USER, PASSWORD)), (self.redirects[start], None)])
|
||||
|
||||
|
||||
class ScopedHandler(Base):
|
||||
"""Handler(scoped=True), for callers (artwork-like) that must stay inside a scope, redirects included."""
|
||||
|
||||
def route(self, url, auth):
|
||||
if url == PRIVATE + 'icons/a.png':
|
||||
return 302, {'Location': PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'}, b''
|
||||
return 200, {}, PNG
|
||||
|
||||
def test_redirect_into_a_traversal_is_stripped(self):
|
||||
login = _credentials.basic(PRIVATE, USER, PASSWORD)
|
||||
opener = urllib.request.build_opener(_credentials.Handler(login, scoped=True))
|
||||
with opener.open(PRIVATE + 'icons/a.png') as r:
|
||||
self.assertEqual(r.read(), PNG)
|
||||
self.assertEqual(self.web.seen, [(PRIVATE + 'icons/a.png', basic(USER, PASSWORD)),
|
||||
(PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', None)])
|
||||
for url in TRAVERSALS:
|
||||
self.assertFalse(_credentials.in_scope(login, url), url)
|
||||
self.assertTrue(_credentials.in_scope(login, PRIVATE + 'a/b%20c.png?x=1'))
|
||||
self.assertTrue(_credentials.in_scope(login, PRIVATE.rstrip('/')))
|
||||
|
||||
|
||||
CHURN = '''
|
||||
import sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from apk_sources import _credentials
|
||||
for i in range(int(sys.argv[3])):
|
||||
key = sys.argv[2] + str(i)
|
||||
_credentials.put(key, _credentials.basic('https://h.example/', 'u', 'p'))
|
||||
if i % 2:
|
||||
_credentials.remove(key)
|
||||
'''
|
||||
|
||||
|
||||
class TwoProcesses(Base):
|
||||
def test_interleaved_saves_and_removals_are_not_lost(self):
|
||||
ui = str(Path(__file__).resolve().parents[1] / 'ui')
|
||||
env = dict(os.environ, FRAME_CONTROL_DATA_DIR=str(self.root / 'data'))
|
||||
count = 60
|
||||
procs = [subprocess.Popen([sys.executable, '-c', CHURN, ui, prefix, str(count)], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE) for prefix in ('a', 'b')]
|
||||
for p in procs:
|
||||
out, err = p.communicate(timeout=120)
|
||||
self.assertEqual(p.returncode, 0, err.decode())
|
||||
saved = set(json.loads(self.credentials_file().read_text()))
|
||||
self.assertEqual(saved, {p + str(i) for p in 'ab' for i in range(0, count, 2)})
|
||||
if os.name != 'nt':
|
||||
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'needs node')
|
||||
class PageLogin(unittest.TestCase):
|
||||
def test_login_leaves_the_address_field_before_sending(self):
|
||||
"""tests/page/source_login.mjs runs the real page functions from index.html."""
|
||||
r = subprocess.run(['node', str(Path(__file__).parent / 'page' / 'source_login.mjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
self.assertIn('ok', r.stdout)
|
||||
|
||||
|
||||
class Endpoints(Repository):
|
||||
"""The server API: logins in, never out, in responses or the server log."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
mock = patch.object(search, 'modules', return_value=([fdroid, github], []))
|
||||
mock.start()
|
||||
self.addCleanup(mock.stop)
|
||||
self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
||||
self.addCleanup(self.httpd.server_close)
|
||||
self.addCleanup(self.httpd.shutdown)
|
||||
|
||||
def request(self, method, path, body=None):
|
||||
c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port)
|
||||
c.request(method, path, json.dumps(body) if body is not None else None,
|
||||
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
|
||||
r = c.getresponse()
|
||||
raw = r.read().decode()
|
||||
c.close()
|
||||
self.replies.append(raw)
|
||||
return r.status, json.loads(raw)
|
||||
|
||||
def job(self, reply):
|
||||
for _ in range(500):
|
||||
job = self.request('GET', '/api/job?id=' + reply['job'])[1]
|
||||
if job['done']:
|
||||
return job
|
||||
time.sleep(.01)
|
||||
self.fail('job did not finish')
|
||||
|
||||
def test_add_with_login_and_token_settings(self):
|
||||
self.replies = []
|
||||
bad = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
|
||||
'username': USER, 'password': 'wrong'})[1])
|
||||
self.assertIn('check the username and password', bad['error'])
|
||||
self.assertEqual(fdroid.user_repos(), [])
|
||||
code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
|
||||
'username': USER, 'password': PASSWORD})
|
||||
self.assertEqual(code, 200)
|
||||
job = self.job(reply)
|
||||
self.assertIsNone(job['error'])
|
||||
listed = self.request('GET', '/api/sources')[1]['sources']
|
||||
repo = next(s for s in listed if s['id'] == job['result']['source']['id'])
|
||||
self.assertEqual((repo['url'], repo['login']), (REPO, True))
|
||||
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'github',
|
||||
'token': TOKEN})[0], 200)
|
||||
github_row = next(s for s in self.request('GET', '/api/sources')[1]['sources'] if s['id'] == 'github')
|
||||
self.assertEqual(github_row['token'], {'set': True, 'from': 'saved', 'rejected': False})
|
||||
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'fdroid',
|
||||
'token': TOKEN})[0], 400)
|
||||
self.assertEqual(self.request('POST', '/api/sources', {'action': 'clear-token', 'source': 'github'})[0], 200)
|
||||
self.assertFalse(_credentials.has('github'))
|
||||
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': repo['id']})[0], 200)
|
||||
self.assertFalse(self.credentials_file().exists())
|
||||
self.assertNoSecrets(*self.replies, self.stderr.getvalue(), self.disk())
|
||||
|
||||
def test_login_in_the_url_is_accepted(self):
|
||||
self.replies = []
|
||||
url = 'https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo'
|
||||
job = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': url, 'fingerprint': PIN})[1])
|
||||
self.assertIsNone(job['error'])
|
||||
self.assertEqual(fdroid.user_repos()[0]['url'], REPO)
|
||||
self.assertNoSecrets(*self.replies, self.stderr.getvalue())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tracking protocols and fake-Frame BlueZ lifecycle; no headset or strap needed."""
|
||||
import importlib.util
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("tracking", Path(__file__).resolve().parents[1] / "frame/tracking/tracking.py")
|
||||
t = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(t)
|
||||
|
||||
|
||||
class Protocols(unittest.TestCase):
|
||||
def test_hrs_formats(self):
|
||||
self.assertEqual(t.heart_rate(b"\x00\x48"), 72)
|
||||
self.assertEqual(t.heart_rate(b"\x01\x2c\x01"), 300)
|
||||
self.assertEqual(t.heart_rate(b"\x1e\x48\x01\x00\x00\x04\x00\x04"), 72)
|
||||
self.assertEqual(t.heart_rate(b"\x02\x48"), 72) # contact not supported
|
||||
self.assertIsNone(t.heart_rate(b"\x04\x48")) # no contact
|
||||
self.assertIsNone(t.heart_rate(b"\x00\x00"))
|
||||
|
||||
def test_hrs_malformed(self):
|
||||
for packet in (b"", b"\x00", b"\x01\x48", b"\x08\x48\x00", b"\x10\x48",
|
||||
b"\x10\x48\x00", b"\x00\x48\x01", b"\xe0\x48"):
|
||||
with self.subTest(packet=packet), self.assertRaises(ValueError):
|
||||
t.heart_rate(packet)
|
||||
|
||||
def test_gaze_coordinates(self):
|
||||
self.assertEqual(t.gaze_angles([0, 0, 0, 1]), (0, 0))
|
||||
angle = math.radians(15)
|
||||
pitch, yaw = t.gaze_angles([math.sin(angle), 0, 0, math.cos(angle)])
|
||||
self.assertAlmostEqual(pitch, -30) # OpenXR +X rotation looks up
|
||||
self.assertAlmostEqual(yaw, 0)
|
||||
pitch, yaw = t.gaze_angles([0, -math.sin(angle), 0, math.cos(angle)])
|
||||
self.assertAlmostEqual(pitch, 0)
|
||||
self.assertAlmostEqual(yaw, 30) # right
|
||||
|
||||
def test_invalid_gaze(self):
|
||||
for pose in ([0, 0, 0, 0], [math.nan, 0, 0, 1], [0, 0, 0], [0, 0, 0, math.inf]):
|
||||
with self.assertRaises(ValueError):
|
||||
t.gaze_angles(pose)
|
||||
|
||||
def test_osc_wire(self):
|
||||
self.assertEqual(t.osc_message('/x', [72]), b'/x\0\0,i\0\0' + struct.pack('>i', 72))
|
||||
self.assertEqual(t.osc_message('/x', [1.0, -2.0]), b'/x\0\0,ff\0' + struct.pack('>ff', 1, -2))
|
||||
for address in ('x', '/x\0y', '/x y', '/x*'):
|
||||
with self.assertRaises(ValueError):
|
||||
t.osc_message(address, [1])
|
||||
|
||||
def test_default_never_opens_socket(self):
|
||||
with patch.object(t.socket, 'socket') as create:
|
||||
osc = t.Osc()
|
||||
osc.send('/x', [72])
|
||||
osc.close()
|
||||
create.assert_not_called()
|
||||
|
||||
def test_only_configured_endpoint(self):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as receiver:
|
||||
receiver.bind(('127.0.0.1', 0))
|
||||
receiver.settimeout(1)
|
||||
osc = t.Osc(receiver.getsockname())
|
||||
try:
|
||||
osc.send('/tracking/eye/CenterPitchYaw', [0.0, 30.0])
|
||||
self.assertEqual(receiver.recv(1024), t.osc_message('/tracking/eye/CenterPitchYaw', [0.0, 30.0]))
|
||||
finally:
|
||||
osc.close()
|
||||
|
||||
def test_endpoint_validation(self):
|
||||
for endpoint in [('example.org', 9000), ('0.0.0.0', 9000), ('224.0.0.1', 9000), ('127.0.0.1', 0), ('::1', 65536)]:
|
||||
with self.assertRaises(ValueError):
|
||||
t.Osc(endpoint)
|
||||
|
||||
def test_heart_staleness_contact_and_log(self):
|
||||
now = [0]
|
||||
osc = Mock()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'session.csv'
|
||||
session = t.HeartSession(osc, '/hr', path, lambda: now[0])
|
||||
self.assertIsNone(session.current())
|
||||
session.notification(b'\x00\x48')
|
||||
self.assertEqual(session.current(), 72)
|
||||
osc.send.assert_called_once_with('/hr', [72])
|
||||
now[0] = 6
|
||||
self.assertIsNone(session.current())
|
||||
session.notification(b'\x04\x48')
|
||||
self.assertIsNone(session.current())
|
||||
self.assertEqual(osc.send.call_count, 1)
|
||||
session.close()
|
||||
self.assertEqual(path.read_text().splitlines()[0], 'unix_seconds,bpm')
|
||||
self.assertEqual(len(path.read_text().splitlines()), 2)
|
||||
if os.name != 'nt':
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
with self.assertRaises(FileExistsError):
|
||||
t.HeartSession(osc, '/hr', path)
|
||||
|
||||
def test_no_log_by_default(self):
|
||||
with patch.object(t.os, 'open') as create:
|
||||
session = t.HeartSession(Mock(), '/hr')
|
||||
session.notification(b'\x00\x48')
|
||||
session.close()
|
||||
create.assert_not_called()
|
||||
|
||||
|
||||
class FakeBluez(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.device = '/org/bluez/hci0/dev_TEST'
|
||||
self.service = self.device + '/service1'
|
||||
self.char = self.service + '/char1'
|
||||
self.objects = {
|
||||
self.device: {t.DEVICE: {'Address': 'AA:BB:CC:DD:EE:FF', 'Connected': False, 'ServicesResolved': True}},
|
||||
self.service: {t.SERVICE: {'UUID': t.HRS, 'Device': self.device}},
|
||||
self.char: {t.CHARACTERISTIC: {'UUID': t.MEASUREMENT, 'Service': self.service, 'Flags': ['notify']}},
|
||||
}
|
||||
self.api = Mock()
|
||||
self.api.GetManagedObjects.side_effect = lambda: self.objects
|
||||
self.bus = Mock()
|
||||
self.interface = Mock(return_value=self.api)
|
||||
self.values = Mock()
|
||||
|
||||
def reader(self):
|
||||
return t.BluezHeart(self.bus, self.interface, 'AA:BB:CC:DD:EE:FF', self.values)
|
||||
|
||||
def test_subscribe_receive_and_cleanup(self):
|
||||
reader = self.reader()
|
||||
self.api.Connect.assert_called_once()
|
||||
self.assertTrue(reader.subscribe())
|
||||
self.api.StartNotify.assert_called_once()
|
||||
reader.changed(t.CHARACTERISTIC, {'Value': [0, 72]}, [], self.char)
|
||||
self.values.assert_called_once_with([0, 72])
|
||||
reader.changed(t.CHARACTERISTIC, {'Value': [0, 73]}, [], '/other/strap')
|
||||
self.assertEqual(self.values.call_count, 1)
|
||||
reader.close()
|
||||
self.api.StopNotify.assert_called_once()
|
||||
self.api.Disconnect.assert_called_once()
|
||||
self.bus.add_signal_receiver.return_value.remove.assert_called_once()
|
||||
|
||||
def test_preserve_existing_connection(self):
|
||||
self.objects[self.device][t.DEVICE]['Connected'] = True
|
||||
reader = self.reader()
|
||||
reader.subscribe()
|
||||
reader.close()
|
||||
self.api.Connect.assert_not_called()
|
||||
self.api.Disconnect.assert_not_called()
|
||||
|
||||
def test_only_selected_device_service(self):
|
||||
self.objects[self.service][t.SERVICE]['Device'] = '/other/device'
|
||||
reader = self.reader()
|
||||
try:
|
||||
with self.assertRaises(RuntimeError):
|
||||
reader.subscribe()
|
||||
finally:
|
||||
reader.close()
|
||||
self.api.StartNotify.assert_not_called()
|
||||
|
||||
def test_wait_for_services(self):
|
||||
self.objects[self.device][t.DEVICE]['ServicesResolved'] = False
|
||||
reader = self.reader()
|
||||
self.assertFalse(reader.subscribe())
|
||||
reader.close()
|
||||
self.api.StartNotify.assert_not_called()
|
||||
self.api.StopNotify.assert_not_called()
|
||||
|
||||
def test_disconnect_notification(self):
|
||||
reader = self.reader()
|
||||
reader.changed(t.DEVICE, {'Connected': 0}, [], self.device) # dbus.Boolean behaves as int
|
||||
self.values.assert_called_once_with(None)
|
||||
reader.close()
|
||||
|
||||
def test_failed_notify_cleans_connection(self):
|
||||
reader = self.reader()
|
||||
self.api.StartNotify.side_effect = RuntimeError('failure')
|
||||
with self.assertRaises(RuntimeError):
|
||||
reader.subscribe()
|
||||
reader.close()
|
||||
self.api.StopNotify.assert_not_called()
|
||||
self.api.Disconnect.assert_called_once()
|
||||
|
||||
def test_unknown_device_does_not_connect_or_scan(self):
|
||||
self.objects.clear()
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.reader()
|
||||
self.api.Connect.assert_not_called()
|
||||
self.api.StartDiscovery.assert_not_called()
|
||||
@@ -45,6 +45,10 @@ class SourceError(Exception):
|
||||
"""User-readable failure from a source (network, format, verification)."""
|
||||
|
||||
|
||||
class SourceAuthError(SourceError):
|
||||
"""The server refused our login (HTTP 401, or 403 when a login was sent)."""
|
||||
|
||||
|
||||
class SourceLimited(SourceError):
|
||||
"""The source's host asked us to slow down; retry_after is in seconds."""
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Logins for sources behind one: HTTP basic auth and access tokens.
|
||||
|
||||
Saved apart from the source settings, in frame_host.data_dir('source-credentials.json')
|
||||
(mode 0600), keyed by a caller-chosen name: an F-Droid source id, 'github'. Each
|
||||
credential belongs to one exact origin (https, host, port) and is only ever sent
|
||||
to a request for that origin: Handler adds it to each request, redirects
|
||||
included, and removes any Authorization header from every other request.
|
||||
|
||||
Scoped use (for_url, and Handler(..., scoped=True), which F-Droid repositories and
|
||||
artwork use) is stricter: the URL must also be under the credential's scope by whole
|
||||
path segments, with no dot segments, encoded slashes or ';' a server could normalise
|
||||
into a path outside it. Origin-only matching suits a host-wide token (GitHub's API).
|
||||
|
||||
Secrets leave this module only as an Authorization header value. Credential's
|
||||
repr hides them, and nothing here puts them in an exception message.
|
||||
|
||||
Each change is a read-modify-write under an inter-process lock (the CLI and the
|
||||
app may both save), so one process can't undo another's save or removal.
|
||||
"""
|
||||
import base64
|
||||
import contextlib
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import frame_host
|
||||
|
||||
_lock = threading.RLock()
|
||||
MAX = 4096 # longest username, password or token accepted
|
||||
|
||||
|
||||
def _path():
|
||||
return frame_host.data_dir('source-credentials.json')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _changing():
|
||||
"""This thread, then this process, holds the credentials file for a read-modify-write."""
|
||||
path = _path().with_name('source-credentials.lock')
|
||||
with _lock:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(str(path), 'a+b') as f:
|
||||
if os.name == 'nt':
|
||||
import msvcrt
|
||||
while True:
|
||||
try:
|
||||
f.seek(0)
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
|
||||
break
|
||||
except OSError as e: # LK_LOCK gives up after ~10 s of contention; keep waiting
|
||||
if e.errno not in (errno.EACCES, errno.EDEADLK):
|
||||
raise
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
f.seek(0)
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def path_segments(path):
|
||||
"""A URL path's decoded segments, or None if a server might read it as another path:
|
||||
'.' or '..' (plain or percent-encoded), encoded '/' or '\\', '%' left after decoding
|
||||
(double encoding), or ';' path parameters."""
|
||||
if ';' in path or '\\' in path:
|
||||
return None
|
||||
parts = [urllib.parse.unquote(part) for part in path.split('/')]
|
||||
if any(part in ('.', '..') or '/' in part or '\\' in part or '%' in part or ';' in part for part in parts):
|
||||
return None
|
||||
return parts
|
||||
|
||||
|
||||
def in_scope(credential, url):
|
||||
"""True if url is on credential's origin and under its scope by whole, plain path segments."""
|
||||
if credential is None or not credential.matches(url):
|
||||
return False
|
||||
try:
|
||||
path, scope = urllib.parse.urlsplit(url).path, urllib.parse.urlsplit(credential.scope).path
|
||||
except ValueError:
|
||||
return False
|
||||
parts, base = path_segments(path), path_segments(scope)
|
||||
if parts is None or base is None:
|
||||
return False
|
||||
if base and base[-1] == '':
|
||||
base = base[:-1] # 'https://h/repo/' covers '/repo/x', not '/repository'
|
||||
return parts[:len(base)] == base
|
||||
|
||||
|
||||
def origin(url):
|
||||
"""'https://host:port' for an https URL; ValueError for anything else."""
|
||||
p = urllib.parse.urlsplit(url)
|
||||
port = p.port # raises ValueError on a bad port
|
||||
if p.scheme.lower() != 'https' or not p.hostname:
|
||||
raise ValueError('credentials are only sent over HTTPS')
|
||||
host = p.hostname.lower()
|
||||
return 'https://%s:%d' % ('[%s]' % host if ':' in host else host, port or 443)
|
||||
|
||||
|
||||
def _check(value, what, allow_empty=False):
|
||||
if not isinstance(value, str) or (not value and not allow_empty) or len(value) > MAX:
|
||||
raise ValueError('invalid ' + what)
|
||||
if any(ord(c) < 32 or ord(c) == 127 for c in value):
|
||||
raise ValueError(what + ' must not contain control characters')
|
||||
return value
|
||||
|
||||
|
||||
class Credential:
|
||||
"""A username and password (basic) or a token (bearer) for one origin.
|
||||
|
||||
scope is the URL prefix it belongs under: in_scope checks it (by path segments),
|
||||
matches() checks only the origin."""
|
||||
__slots__ = ('origin', 'scope', 'username', '_secret')
|
||||
|
||||
def __init__(self, scope, secret, username=None):
|
||||
self.origin = origin(scope)
|
||||
self.scope = scope
|
||||
if username is not None:
|
||||
_check(username, 'username')
|
||||
if ':' in username:
|
||||
raise ValueError('username must not contain ":"')
|
||||
_check(secret, 'password', allow_empty=True)
|
||||
else:
|
||||
_check(secret, 'token')
|
||||
if any(c.isspace() for c in secret):
|
||||
raise ValueError('token must not contain spaces')
|
||||
self.username, self._secret = username, secret
|
||||
|
||||
@property
|
||||
def kind(self):
|
||||
return 'bearer' if self.username is None else 'basic'
|
||||
|
||||
def header(self):
|
||||
if self.username is None:
|
||||
return 'Bearer ' + self._secret
|
||||
return 'Basic ' + base64.b64encode((self.username + ':' + self._secret).encode('utf-8')).decode('ascii')
|
||||
|
||||
def matches(self, url):
|
||||
"""True only for a URL on exactly this credential's origin."""
|
||||
try:
|
||||
return origin(url) == self.origin
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def same(self, other):
|
||||
return isinstance(other, Credential) and (self.origin, self.username, self._secret) == (
|
||||
other.origin, other.username, other._secret)
|
||||
|
||||
def __repr__(self):
|
||||
return '<Credential %s for %s>' % (self.kind, self.origin)
|
||||
|
||||
def _json(self):
|
||||
return {'scope': self.scope, 'username': self.username, 'secret': self._secret}
|
||||
|
||||
|
||||
def basic(scope, username, password):
|
||||
"""Basic auth for scope's origin, e.g. basic('https://example.org/fdroid/repo/', 'me', 'pw')."""
|
||||
return Credential(scope, password, username)
|
||||
|
||||
|
||||
def bearer(scope, token):
|
||||
"""An access token for scope's origin, sent as 'Authorization: Bearer <token>'."""
|
||||
return Credential(scope, token)
|
||||
|
||||
|
||||
def _read():
|
||||
try:
|
||||
with open(str(_path()), encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (OSError, ValueError):
|
||||
return {} # unreadable: as if none were saved; the next save rewrites it
|
||||
|
||||
|
||||
def _save(data):
|
||||
path = _path()
|
||||
if not data:
|
||||
try:
|
||||
os.remove(str(path))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.credentials-', suffix='.part') # created 0600
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, separators=(',', ':'))
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, str(path))
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
def get(key):
|
||||
"""The saved Credential for key, or None."""
|
||||
with _lock:
|
||||
item = _read().get(key)
|
||||
try:
|
||||
return Credential(item['scope'], item['secret'], item.get('username'))
|
||||
except (TypeError, KeyError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def has(key):
|
||||
return get(key) is not None
|
||||
|
||||
|
||||
def put(key, credential):
|
||||
if not isinstance(credential, Credential):
|
||||
raise TypeError('put() needs a Credential')
|
||||
with _changing():
|
||||
data = _read()
|
||||
data[key] = credential._json()
|
||||
_save(data)
|
||||
|
||||
|
||||
def remove(key):
|
||||
"""Delete key's credential from disk; True if there was one."""
|
||||
with _changing():
|
||||
data = _read()
|
||||
if key not in data:
|
||||
return False
|
||||
del data[key]
|
||||
_save(data)
|
||||
return True
|
||||
|
||||
|
||||
def for_url(url):
|
||||
"""A saved credential url is in the scope of (see in_scope), or None. Call it again for
|
||||
each redirect hop: the answer depends only on the URL being requested."""
|
||||
with _lock:
|
||||
keys = list(_read())
|
||||
for key in keys:
|
||||
credential = get(key)
|
||||
if in_scope(credential, url):
|
||||
return credential
|
||||
return None
|
||||
|
||||
|
||||
class Handler(urllib.request.BaseHandler):
|
||||
"""Adds the credential to requests for its origin (redirects included) and strips
|
||||
Authorization from every other request, so a redirect elsewhere never gets it.
|
||||
scoped=True also requires each request, redirects included, to be in_scope."""
|
||||
handler_order = 900 # after the default processors
|
||||
|
||||
def __init__(self, credential, scoped=False):
|
||||
self.credential, self.scoped = credential, scoped
|
||||
|
||||
def _request(self, req):
|
||||
req.remove_header('Authorization')
|
||||
allowed = in_scope if self.scoped else lambda c, url: c is not None and c.matches(url)
|
||||
if allowed(self.credential, req.full_url):
|
||||
req.add_unredirected_header('Authorization', self.credential.header())
|
||||
return req
|
||||
|
||||
http_request = https_request = _request
|
||||
@@ -9,7 +9,7 @@ import threading
|
||||
import time
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from apk_sources import SourceError
|
||||
from apk_sources import SourceError, _credentials
|
||||
|
||||
_lock = threading.Lock()
|
||||
_urls = OrderedDict()
|
||||
@@ -141,7 +141,11 @@ def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
|
||||
path = p.path or '/'
|
||||
if p.query:
|
||||
path += '?' + p.query
|
||||
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
|
||||
sent = {'User-Agent': 'FrameControl/0.3.1', **(headers or {})}
|
||||
login = _credentials.for_url(url) # a repository behind a login: its own artwork only, per hop
|
||||
if login:
|
||||
sent['Authorization'] = login.header()
|
||||
conn.request('GET', path, headers=sent)
|
||||
live[0].settimeout(min(10, left()))
|
||||
response = conn.getresponse()
|
||||
if response.status in (301, 302, 303, 307, 308) and redirects:
|
||||
|
||||
+28
-13
@@ -3,7 +3,7 @@ import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.pars
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import frame_host
|
||||
from . import SourceError, SourceLimited
|
||||
from . import SourceAuthError, SourceError, SourceLimited, _credentials
|
||||
|
||||
UA = 'FrameControl/0.1'
|
||||
APK_CAP = 2 * 1024 ** 3 # cached APKs across all sources, least recently used go first
|
||||
@@ -38,7 +38,7 @@ def release(path):
|
||||
else:
|
||||
_in_use.pop(path, None)
|
||||
BACKOFF = 600 # seconds to leave a host alone after 403/429 without Retry-After
|
||||
_limited = {} # host -> time.time() before which we don't contact it
|
||||
_limited = {} # host (+ login identity) -> time.time() before which we don't ask it with that login
|
||||
_limited_lock = threading.Lock()
|
||||
|
||||
|
||||
@@ -46,8 +46,16 @@ def _host(url):
|
||||
return (urllib.parse.urlsplit(url).hostname or '').lower()
|
||||
|
||||
|
||||
def throttle(url, headers=None):
|
||||
"""Remember that url's host asked us to back off; return the delay in seconds."""
|
||||
def _who(url, auth=None):
|
||||
"""Backoff key: the host, per login. A token has its own quota, so anonymous
|
||||
rate limiting mustn't hold back requests made with one (or the reverse)."""
|
||||
if auth is None:
|
||||
return _host(url)
|
||||
return _host(url) + ' ' + hashlib.sha256(auth.header().encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def throttle(url, headers=None, auth=None):
|
||||
"""Remember that url's host asked us (with this login) to back off; return the delay in seconds."""
|
||||
headers = headers or {}
|
||||
now, delay = time.time(), None
|
||||
value = (headers.get('Retry-After') or '').strip()
|
||||
@@ -62,14 +70,15 @@ def throttle(url, headers=None):
|
||||
if delay is None and headers.get('X-RateLimit-Remaining') == '0' and reset.isdigit():
|
||||
delay = int(reset) - now # GitHub
|
||||
delay = min(max(delay if delay is not None else BACKOFF, 1), 6 * 3600)
|
||||
key = _who(url, auth)
|
||||
with _limited_lock:
|
||||
_limited[_host(url)] = max(_limited.get(_host(url), 0), now + delay)
|
||||
_limited[key] = max(_limited.get(key, 0), now + delay)
|
||||
return delay
|
||||
|
||||
|
||||
def wait_time(url):
|
||||
def wait_time(url, auth=None):
|
||||
with _limited_lock:
|
||||
return max(0, _limited.get(_host(url), 0) - time.time())
|
||||
return max(0, _limited.get(_who(url, auth), 0) - time.time())
|
||||
|
||||
|
||||
def limited_error(name, seconds, hint=''):
|
||||
@@ -136,6 +145,8 @@ def checked_url(url, hosts):
|
||||
|
||||
|
||||
class Redirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Only to the allowed hosts, and never carrying an Authorization header along:
|
||||
a credential passed to open_url() is added again, by its Handler, only on its origin."""
|
||||
def __init__(self, hosts):
|
||||
self.hosts = hosts
|
||||
|
||||
@@ -147,13 +158,15 @@ class Redirect(urllib.request.HTTPRedirectHandler):
|
||||
return result
|
||||
|
||||
|
||||
def open_url(url, hosts, headers=None):
|
||||
def open_url(url, hosts, headers=None, auth=None):
|
||||
"""auth: an _credentials.Credential, sent only to requests on its own origin."""
|
||||
checked_url(url, hosts)
|
||||
return urllib.request.build_opener(Redirect(hosts)).open(
|
||||
handlers = [Redirect(hosts)] + ([_credentials.Handler(auth)] if auth else [])
|
||||
return urllib.request.build_opener(*handlers).open(
|
||||
urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60)
|
||||
|
||||
|
||||
def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
|
||||
def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
|
||||
path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data')
|
||||
name = name or _host(url) or 'The source'
|
||||
|
||||
@@ -165,13 +178,13 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
|
||||
if os.path.isfile(path) and time.time() - os.path.getmtime(path) < ttl:
|
||||
with open(path, 'rb') as f:
|
||||
return f.read()
|
||||
wait = wait_time(url)
|
||||
wait = wait_time(url, auth)
|
||||
if wait:
|
||||
data = cached()
|
||||
if data is None:
|
||||
raise limited_error(name, wait, hint)
|
||||
return data
|
||||
with open_url(url, hosts, headers) as r:
|
||||
with open_url(url, hosts, headers, auth) as r:
|
||||
data = r.read(8 * 1024 * 1024 + 1)
|
||||
if len(data) > 8 * 1024 * 1024:
|
||||
raise SourceError('Source index is too large')
|
||||
@@ -185,8 +198,10 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
|
||||
os.remove(tmp)
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401: # not 403: GitHub also uses that for rate limits, token or not
|
||||
raise SourceAuthError('%s refused the login (HTTP 401)' % name) from e
|
||||
if e.code in (403, 429):
|
||||
delay = throttle(url, e.headers)
|
||||
delay = throttle(url, e.headers, auth)
|
||||
data = cached()
|
||||
if data is None:
|
||||
raise limited_error(name, delay, hint) from e
|
||||
|
||||
+82
-16
@@ -20,7 +20,7 @@ import zipfile
|
||||
|
||||
if __package__ in (None, ''):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from apk_sources import SourceError, SourceLimited, _web
|
||||
from apk_sources import SourceAuthError, SourceError, SourceLimited, _credentials, _web
|
||||
import frame_host
|
||||
from frame_apk_sign import _der_parts, _cert_key, der
|
||||
from frame_catalog import _IndexReader, _reduce_index, _sha256
|
||||
@@ -71,6 +71,39 @@ def _url(url, fingerprint=None):
|
||||
return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin
|
||||
|
||||
|
||||
def _split_login(url, username=None, password=None):
|
||||
"""(url without user:pass, credential or None). The address may carry the login, as
|
||||
F-Droid's client accepts (https://user:pass@host/fdroid/repo), or it may be given apart."""
|
||||
try:
|
||||
p = urllib.parse.urlsplit(url)
|
||||
p.port
|
||||
except ValueError as e:
|
||||
raise SourceError('invalid repository URL') from e
|
||||
if '@' in p.netloc:
|
||||
if p.username is None:
|
||||
raise SourceError('invalid repository URL')
|
||||
linked = urllib.parse.unquote(p.username), None if p.password is None else urllib.parse.unquote(p.password)
|
||||
if username not in (None, '', linked[0]) or (linked[1] is not None and password not in (None, '', linked[1])):
|
||||
raise SourceError('the address and the username/password fields disagree')
|
||||
username = linked[0]
|
||||
password = linked[1] if linked[1] is not None else password
|
||||
url = urllib.parse.urlunsplit((p.scheme, p.netloc.rpartition('@')[2], p.path, p.query, p.fragment))
|
||||
if username in (None, ''):
|
||||
if password:
|
||||
raise SourceError('a password needs a username')
|
||||
return url, None
|
||||
clean, _ = _url(url)
|
||||
try:
|
||||
return url, _credentials.basic(clean, username, password or '')
|
||||
except ValueError as e:
|
||||
raise SourceError('cannot use that login: ' + str(e)) from None
|
||||
|
||||
|
||||
def _login(source):
|
||||
"""The saved login for a user repository, or None."""
|
||||
return None if source.get('builtin') else _credentials.get(source['id'])
|
||||
|
||||
|
||||
def _child(base, name):
|
||||
name = str(name).lstrip('/')
|
||||
decoded = urllib.parse.unquote(name)
|
||||
@@ -81,6 +114,9 @@ def _child(base, name):
|
||||
url = urllib.parse.urljoin(base, urllib.parse.quote(decoded, safe='/')) # names may contain '#' or spaces
|
||||
if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment:
|
||||
raise SourceError('repository file is outside its repository')
|
||||
# Nothing a server could normalise elsewhere: double-encoded dots, ';' parameters, encoded slashes.
|
||||
if _credentials.path_segments(urllib.parse.urlsplit(url).path) is None:
|
||||
raise SourceError('unsafe repository file name')
|
||||
return url
|
||||
|
||||
|
||||
@@ -91,14 +127,17 @@ class _HTTPSRedirect(urllib.request.HTTPRedirectHandler):
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def _fetch(url, path, maximum):
|
||||
def _fetch(url, path, maximum, auth=None):
|
||||
"""auth: the repository's login, sent only inside the repository (its origin and directory,
|
||||
by whole plain path segments), on every request including redirects."""
|
||||
host = urllib.parse.urlsplit(url).hostname
|
||||
wait = _web.wait_time(url)
|
||||
wait = _web.wait_time(url, auth)
|
||||
if wait:
|
||||
raise _web.limited_error(host, wait)
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'})
|
||||
handlers = [_HTTPSRedirect()] + ([_credentials.Handler(auth, scoped=True)] if auth else [])
|
||||
try:
|
||||
with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f:
|
||||
with urllib.request.build_opener(*handlers).open(request, timeout=60) as r, open(path, 'wb') as f:
|
||||
total = 0
|
||||
while True:
|
||||
chunk = r.read(1 << 20)
|
||||
@@ -109,8 +148,11 @@ def _fetch(url, path, maximum):
|
||||
raise SourceError('repository file exceeds size limit')
|
||||
f.write(chunk)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401 or (e.code == 403 and auth):
|
||||
raise SourceAuthError('The repository refused the login (HTTP %d); check the username and password'
|
||||
% e.code if auth else 'This repository needs a username and password') from None
|
||||
if e.code in (403, 429):
|
||||
raise _web.limited_error(host, _web.throttle(url, e.headers)) from e
|
||||
raise _web.limited_error(host, _web.throttle(url, e.headers, auth)) from e
|
||||
raise
|
||||
|
||||
|
||||
@@ -342,7 +384,7 @@ def sources():
|
||||
return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True,
|
||||
# The archive only holds superseded versions; it clutters search unless asked for.
|
||||
enabled=settings['enabled'].get(i, i != 'fdroid-archive'), trust='community')
|
||||
for i, n, u, p in builtins] + settings['repos']
|
||||
for i, n, u, p in builtins] + [dict(s, login=_credentials.has(s['id'])) for s in settings['repos']]
|
||||
|
||||
|
||||
def _text(value):
|
||||
@@ -514,10 +556,12 @@ def _source_lock(source_id):
|
||||
return _load_locks.setdefault(source_id, threading.Lock())
|
||||
|
||||
|
||||
def _load(source, force=False):
|
||||
def _load(source, force=False, auth=None):
|
||||
"""auth: a login to use instead of the saved one (while adding, before anything is saved)."""
|
||||
if not re.fullmatch(r'[a-z0-9-]+', source['id']):
|
||||
raise SourceError('invalid source id')
|
||||
_url(source['url'], source.get('fingerprint'))
|
||||
auth = auth or _login(source)
|
||||
cache = frame_host.cache_dir('apk-sources', source['id'] + '.json')
|
||||
found = None if force else _cached(source, cache)
|
||||
if found:
|
||||
@@ -535,7 +579,7 @@ def _load(source, force=False):
|
||||
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
|
||||
v2 = True
|
||||
try:
|
||||
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
|
||||
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024, auth=auth)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in (404, 410):
|
||||
raise
|
||||
@@ -543,7 +587,7 @@ def _load(source, force=False):
|
||||
raise SourceError('repository no longer serves its signed v2 index; '
|
||||
'refusing to fall back to the older v1 index') from e
|
||||
v2 = False
|
||||
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
|
||||
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024, auth=auth)
|
||||
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
|
||||
timestamp = _v1(content, raw)
|
||||
_check_timestamp(source, timestamp)
|
||||
@@ -552,7 +596,7 @@ def _load(source, force=False):
|
||||
signed = json.loads(content)
|
||||
timestamp, entry = signed.get('timestamp'), signed['index']
|
||||
_check_timestamp(source, timestamp)
|
||||
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024)
|
||||
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024, auth=auth)
|
||||
if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']):
|
||||
raise SourceError('index SHA-256 or size mismatch')
|
||||
apps = _reduce(raw, source)
|
||||
@@ -572,7 +616,10 @@ def _load(source, force=False):
|
||||
raise SourceError('cannot load repository: ' + str(e)) from e
|
||||
|
||||
|
||||
def add_repo(url, fingerprint=None, name=None):
|
||||
def add_repo(url, fingerprint=None, name=None, username=None, password=None):
|
||||
"""Check and save a repository. A login (in the URL or apart) is kept out of the
|
||||
saved URL, in _credentials, and saved only once the repository's index verified."""
|
||||
url, login = _split_login(url, username, password)
|
||||
url, pin = _url(url, fingerprint)
|
||||
with _LOCK:
|
||||
existing = next((s for s in _read()['repos'] if s['url'] == url), None)
|
||||
@@ -583,15 +630,26 @@ def add_repo(url, fingerprint=None, name=None):
|
||||
source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND,
|
||||
name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname,
|
||||
url=url, builtin=False, enabled=True, trust='user', fingerprint=pin)
|
||||
_, source['fingerprint'] = _load(source, force=True) # network work outside the settings lock
|
||||
# Network work outside the settings lock; without a new login, a saved one (re-adding) is used.
|
||||
_, source['fingerprint'] = _load(source, force=True, auth=login)
|
||||
source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None
|
||||
with _LOCK:
|
||||
settings = _read()
|
||||
current = next((s for s in settings['repos'] if s['id'] == source['id']), None)
|
||||
if current and current['fingerprint'] != source['fingerprint']:
|
||||
raise SourceError('repository already has a different pinned fingerprint; remove it first')
|
||||
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
|
||||
_write(_storage(), settings)
|
||||
previous = _credentials.get(source['id'])
|
||||
if login:
|
||||
_credentials.put(source['id'], login)
|
||||
try:
|
||||
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
|
||||
_write(_storage(), settings)
|
||||
except BaseException:
|
||||
if login and previous: # not saved after all: leave the login as it was
|
||||
_credentials.put(source['id'], previous)
|
||||
elif login:
|
||||
_credentials.remove(source['id'])
|
||||
raise
|
||||
return source
|
||||
|
||||
|
||||
@@ -602,6 +660,7 @@ def remove_repo(source_id):
|
||||
raise SourceError('unknown user repository')
|
||||
settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id]
|
||||
_write(_storage(), settings)
|
||||
_credentials.remove(source_id)
|
||||
with _state_file_lock(), _LOCK: # lock order: state file, then _LOCK
|
||||
states = _states()
|
||||
if states.pop(source_id, None) is not None: # re-adding is a deliberate new trust decision
|
||||
@@ -663,7 +722,7 @@ def download(source, entry_id, version_code=None):
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
|
||||
os.close(fd)
|
||||
try:
|
||||
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3)
|
||||
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3, auth=_login(source))
|
||||
if _sha256(tmp) != sha:
|
||||
raise SourceError('APK SHA-256 mismatch; download discarded')
|
||||
os.replace(tmp, path)
|
||||
@@ -684,6 +743,8 @@ def main():
|
||||
add.add_argument('url')
|
||||
add.add_argument('--fingerprint')
|
||||
add.add_argument('--name')
|
||||
add.add_argument('--username', help='for a repository behind a login (HTTP basic auth)')
|
||||
add.add_argument('--password-stdin', action='store_true', help='read the password from the first line of stdin')
|
||||
sub.add_parser('list')
|
||||
remove = sub.add_parser('remove')
|
||||
remove.add_argument('source')
|
||||
@@ -702,7 +763,12 @@ def main():
|
||||
def _cli(parser, args):
|
||||
try:
|
||||
if args.command == 'add':
|
||||
result = add_repo(args.url, args.fingerprint, args.name)
|
||||
password = None
|
||||
if args.password_stdin:
|
||||
if not args.username:
|
||||
raise SourceError('--password-stdin needs --username')
|
||||
password = sys.stdin.readline().rstrip('\r\n')
|
||||
result = add_repo(args.url, args.fingerprint, args.name, args.username, password)
|
||||
elif args.command == 'list':
|
||||
result = sources()
|
||||
elif args.command == 'remove':
|
||||
|
||||
@@ -1,17 +1,77 @@
|
||||
"""Curated publisher releases; optional topic discovery is page-only."""
|
||||
import fnmatch, json, os, re, urllib.parse
|
||||
import fnmatch, hashlib, json, os, re, sys, threading, urllib.parse
|
||||
|
||||
from . import SourceError, _web
|
||||
from . import SourceAuthError, SourceError, _credentials, _web
|
||||
|
||||
KIND = 'github'
|
||||
API = 'https://api.github.com'
|
||||
ENV = 'FRAME_GITHUB_TOKEN' # wins over a token saved in source settings
|
||||
CREDENTIAL = 'github' # _credentials key; only ever sent to api.github.com
|
||||
_rejected = set() # SHA-256 of tokens GitHub answered 401 to (this run only)
|
||||
_rejected_lock = threading.Lock()
|
||||
TOPICS = ('oculus-quest', 'openxr', 'quest')
|
||||
HOSTS = ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': KIND, 'kind': KIND, 'name': 'GitHub releases', 'url': 'https://github.com',
|
||||
'builtin': True, 'enabled': True, 'trust': 'community'}]
|
||||
'builtin': True, 'enabled': True, 'trust': 'community', 'token': token_status()}]
|
||||
|
||||
|
||||
def _token():
|
||||
"""(token, where it came from) or (None, None). The environment variable wins."""
|
||||
env = os.environ.get(ENV)
|
||||
if env:
|
||||
return env, 'env'
|
||||
saved = _credentials.get(CREDENTIAL)
|
||||
if saved and saved.kind == 'bearer':
|
||||
return saved, 'saved'
|
||||
return None, None
|
||||
|
||||
|
||||
def _credential(token):
|
||||
if isinstance(token, _credentials.Credential):
|
||||
return token
|
||||
try:
|
||||
return _credentials.bearer(API + '/', token)
|
||||
except ValueError as e:
|
||||
raise SourceError('%s is not a usable GitHub token: %s' % (ENV, e)) from None
|
||||
|
||||
|
||||
def _digest(credential):
|
||||
return hashlib.sha256(credential.header().encode()).hexdigest()
|
||||
|
||||
|
||||
def token_status():
|
||||
"""Whether a token is set, where from, and whether GitHub rejected it; never the token."""
|
||||
token, where = _token()
|
||||
rejected = False
|
||||
if token is not None:
|
||||
try:
|
||||
with _rejected_lock:
|
||||
rejected = _digest(_credential(token)) in _rejected
|
||||
except SourceError:
|
||||
rejected = True
|
||||
return {'set': token is not None, 'from': where, 'rejected': rejected}
|
||||
|
||||
|
||||
def set_token(token):
|
||||
"""Save a personal access token for api.github.com (FRAME_GITHUB_TOKEN still wins)."""
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
raise SourceError('Paste a GitHub token')
|
||||
try:
|
||||
credential = _credentials.bearer(API + '/', token.strip())
|
||||
except ValueError as e:
|
||||
raise SourceError('That is not a usable GitHub token: ' + str(e)) from None
|
||||
_credentials.put(CREDENTIAL, credential)
|
||||
with _rejected_lock:
|
||||
_rejected.discard(_digest(credential)) # a fresh chance
|
||||
return {'message': 'GitHub token saved' + (' (%s is set and is used instead)' % ENV if os.environ.get(ENV) else '')}
|
||||
|
||||
|
||||
def clear_token():
|
||||
_credentials.remove(CREDENTIAL)
|
||||
return {'message': 'GitHub token removed'}
|
||||
|
||||
|
||||
def _curated():
|
||||
@@ -20,13 +80,33 @@ def _curated():
|
||||
|
||||
|
||||
def _api(path):
|
||||
"""GET from the GitHub API, with the user's token if there is one. If GitHub rejects
|
||||
the token (401), retry once without it, like Obtainium, and remember the rejection."""
|
||||
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
|
||||
token = os.environ.get('FRAME_GITHUB_TOKEN')
|
||||
if token:
|
||||
headers['Authorization'] = 'Bearer ' + token
|
||||
token, _ = _token()
|
||||
auth = _credential(token) if token is not None else None
|
||||
with _rejected_lock:
|
||||
if auth and _digest(auth) in _rejected:
|
||||
auth = None # already refused this run: don't spend a request on it again
|
||||
hint = ' (add a GitHub token in the store\'s source settings, or set %s, to raise the limit)' % ENV
|
||||
if token is not None and not auth:
|
||||
hint = ' (GitHub rejected your token; replace it in the store\'s source settings)'
|
||||
|
||||
def get(auth):
|
||||
return _web.read(API + path, ('api.github.com',), headers, name='GitHub',
|
||||
hint='' if auth else hint, auth=auth)
|
||||
try:
|
||||
return json.loads(_web.read(API + path, ('api.github.com',), headers, name='GitHub',
|
||||
hint='' if token else ' (set FRAME_GITHUB_TOKEN to raise the limit)'))
|
||||
try:
|
||||
data = get(auth)
|
||||
except SourceAuthError:
|
||||
if not auth:
|
||||
raise
|
||||
with _rejected_lock:
|
||||
_rejected.add(_digest(auth))
|
||||
print('GitHub rejected the access token (HTTP 401); continuing without it', file=sys.stderr)
|
||||
hint = ' (GitHub rejected your token; replace it in the store\'s source settings)'
|
||||
data = get(None)
|
||||
return json.loads(data)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise SourceError('Invalid GitHub response') from e
|
||||
|
||||
|
||||
@@ -113,6 +113,14 @@ def manage_repo(action, **kwargs):
|
||||
return getattr(module, action)(**kwargs)
|
||||
|
||||
|
||||
def set_token(source_id, token):
|
||||
"""Save (token) or remove (None) a source's access token; status is in sources()."""
|
||||
module, _ = resolve(source_id)
|
||||
if not hasattr(module, 'set_token'):
|
||||
raise SourceError('This source does not use an access token')
|
||||
return module.set_token(token) if token is not None else module.clear_token()
|
||||
|
||||
|
||||
def normalise(name):
|
||||
return ' '.join(re.findall(r'\w+', unicodedata.normalize('NFKC', name or '').casefold()))
|
||||
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the Frame (piped over SSH): UEVR, a flat-to-VR mod for Unreal games.
|
||||
|
||||
Usage: python3 - status APPID # can UEVR run here, is it installed, is the game running
|
||||
python3 - install APPID # download the pinned official release into the game's prefix
|
||||
python3 - start APPID # launch the game if needed, inject UEVR, confirm SteamVR frames
|
||||
python3 - uninstall APPID # remove exactly what install (and UEVR's first run) created
|
||||
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
|
||||
|
||||
Verified 2026-09-29 on SteamOS 0.4.1 (build 20260925.6191901), Proton 11.0
|
||||
ARM64 and SteamVR 2.18.1 with Gravitas (1067310): after injection the game has
|
||||
UEVRBackend.dll loaded and SteamVR counts frame submits for it. Stereo image,
|
||||
head tracking and controls were not seen: the headset was unworn (docs/mods.md).
|
||||
|
||||
UEVR's own injector (a .NET WPF app) is not used. Under Wine and FEX it hung
|
||||
or ignored input in about one session in four, and gamescope keeps real input
|
||||
away from it. Instead the official Windows embeddable Python runs INJECT_PY in
|
||||
the game's Wine session, doing what the injector does on Inject.
|
||||
|
||||
Nothing here is bundled. UEVR comes from praydog's GitHub release and Python
|
||||
from python.org, each checked against a pinned hash. Files live in
|
||||
<prefix>/drive_c/frame-control, never in the game's own folder, and a receipt
|
||||
records what to remove.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
STEAM = HOME / ".local/share/Steam"
|
||||
CACHE = HOME / ".cache/frame-control/mods"
|
||||
RECEIPTS = HOME / ".local/share/frame-control/mods"
|
||||
LOCK = Path("/tmp/frame-control-mods.lock") # tmpfs: gone at reboot, never left in ~
|
||||
VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
|
||||
MANAGED = "frame-control" # drive_c/<this> inside the game's prefix
|
||||
|
||||
UEVR = {"version": "1.05", "dir": "uevr-1.05",
|
||||
"url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip",
|
||||
"sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"}
|
||||
# Windows x64 embeddable Python, to run INJECT_PY. Hash from python.org's
|
||||
# release-file API (sha256_sum).
|
||||
PYTHON = {"version": "3.14.7", "dir": "python-3.14.7",
|
||||
"url": "https://www.python.org/ftp/python/3.14.7/python-3.14.7-embed-amd64.zip",
|
||||
"sha256": "d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15"}
|
||||
MAX_UNPACKED = 200 << 20 # the two archives unpack to about 50 MB
|
||||
MAX_DOWNLOAD = 50 << 20 # each archive is 7–13 MB
|
||||
|
||||
# The game's environment a second program needs to join its Wine session. Not
|
||||
# WINESERVERSOCKET and friends: those are inherited file descriptors.
|
||||
GAME_ENV = re.compile(r"(WINEPREFIX|WINEDLLPATH|WINEDLLOVERRIDES|WINEFSYNC|WINEDEBUG|PATH|DISPLAY|"
|
||||
r"XDG_RUNTIME_DIR|PROTON_VR_RUNTIME|FEX_APP_CONFIG|FEX_APP_CONFIG_LOCATION|"
|
||||
r"SteamAppId|SteamGameId|STEAM_COMPAT_DATA_PATH|WINE_LARGE_ADDRESS_AWARE)=")
|
||||
|
||||
|
||||
# Runs under Windows Python in the game's Wine session: the steps of UEVR
|
||||
# 1.05's frontend (UEVR/MainWindow.xaml.cs Inject_Clicked, UEVR/Injector.cs)
|
||||
# with "Nullify VR plugins" on and the OpenVR runtime. Prints one JSON line.
|
||||
INJECT_PY = r"""
|
||||
import ctypes, json, os, sys
|
||||
from ctypes import wintypes as W
|
||||
exe, uevr, profile = sys.argv[1:4]
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
H, P, SZ = W.HANDLE, ctypes.c_void_p, ctypes.c_size_t
|
||||
for name, res, args in (
|
||||
("OpenProcess", H, [W.DWORD, W.BOOL, W.DWORD]),
|
||||
("CloseHandle", W.BOOL, [H]),
|
||||
("VirtualAllocEx", P, [H, P, SZ, W.DWORD, W.DWORD]),
|
||||
("VirtualFreeEx", W.BOOL, [H, P, SZ, W.DWORD]),
|
||||
("WriteProcessMemory", W.BOOL, [H, P, P, SZ, P]),
|
||||
("CreateRemoteThread", H, [H, P, SZ, P, P, W.DWORD, P]),
|
||||
("WaitForSingleObject", W.DWORD, [H, W.DWORD]),
|
||||
("GetModuleHandleW", W.HMODULE, [W.LPCWSTR]),
|
||||
("LoadLibraryW", W.HMODULE, [W.LPCWSTR]),
|
||||
("FreeLibrary", W.BOOL, [W.HMODULE]),
|
||||
("GetProcAddress", P, [W.HMODULE, ctypes.c_char_p]),
|
||||
("CreateToolhelp32Snapshot", H, [W.DWORD, W.DWORD]),
|
||||
("Process32FirstW", W.BOOL, [H, P]), ("Process32NextW", W.BOOL, [H, P]),
|
||||
("Module32FirstW", W.BOOL, [H, P]), ("Module32NextW", W.BOOL, [H, P])):
|
||||
fn = getattr(k32, name)
|
||||
fn.restype, fn.argtypes = res, args
|
||||
INVALID = ctypes.c_void_p(-1).value
|
||||
|
||||
class Entry(ctypes.Structure): # PROCESSENTRY32W
|
||||
_fields_ = [("size", W.DWORD), ("usage", W.DWORD), ("pid", W.DWORD), ("heap", ctypes.c_void_p),
|
||||
("module", W.DWORD), ("threads", W.DWORD), ("parent", W.DWORD), ("prio", ctypes.c_long),
|
||||
("flags", W.DWORD), ("name", W.WCHAR * 260)]
|
||||
|
||||
class Module(ctypes.Structure): # MODULEENTRY32W
|
||||
_fields_ = [("size", W.DWORD), ("mid", W.DWORD), ("pid", W.DWORD), ("glbl", W.DWORD), ("proc", W.DWORD),
|
||||
("base", ctypes.c_void_p), ("bytes", W.DWORD), ("handle", W.HMODULE),
|
||||
("name", W.WCHAR * 256), ("path", W.WCHAR * 260)]
|
||||
|
||||
def fail(msg):
|
||||
print(json.dumps({"error": f"{msg} (Windows error {ctypes.get_last_error()})"})); sys.exit(1)
|
||||
|
||||
def snapshot(flags, pid, entry, first, next_, match):
|
||||
snap = k32.CreateToolhelp32Snapshot(flags, pid)
|
||||
if not snap or snap == INVALID:
|
||||
fail("couldn't list processes")
|
||||
try:
|
||||
entry.size = ctypes.sizeof(entry)
|
||||
ok = first(snap, ctypes.byref(entry))
|
||||
while ok:
|
||||
if match(entry):
|
||||
return entry
|
||||
ok = next_(snap, ctypes.byref(entry))
|
||||
finally:
|
||||
k32.CloseHandle(snap)
|
||||
|
||||
def remote_call(proc, fn, arg, wait_ms):
|
||||
t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None)
|
||||
if not t:
|
||||
fail("CreateRemoteThread failed")
|
||||
try:
|
||||
return k32.WaitForSingleObject(t, wait_ms) == 0 # WAIT_OBJECT_0
|
||||
finally:
|
||||
k32.CloseHandle(t)
|
||||
|
||||
def inject(proc, pid, name):
|
||||
path = os.path.join(uevr, name)
|
||||
data = ctypes.create_unicode_buffer(path)
|
||||
mem = k32.VirtualAllocEx(proc, None, ctypes.sizeof(data), 0x3000, 0x04) # commit|reserve, read/write
|
||||
if not mem:
|
||||
fail(f"couldn't allocate in {exe}")
|
||||
try:
|
||||
if not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None):
|
||||
fail(f"couldn't write into {exe}")
|
||||
load = k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW")
|
||||
if not remote_call(proc, load, mem, 30000):
|
||||
fail(f"loading {name} into {exe} didn't finish in 30 s")
|
||||
finally:
|
||||
k32.VirtualFreeEx(proc, mem, 0, 0x8000) # MEM_RELEASE
|
||||
m = snapshot(0x18, pid, Module(), k32.Module32FirstW, k32.Module32NextW, # TH32CS_SNAPMODULE | SNAPMODULE32
|
||||
lambda m: m.path.lower() == path.lower())
|
||||
if not m:
|
||||
fail(f"{name} didn't load into {exe}")
|
||||
return path, m.base
|
||||
|
||||
e = snapshot(2, 0, Entry(), k32.Process32FirstW, k32.Process32NextW, # TH32CS_SNAPPROCESS
|
||||
lambda e: e.name.lower() == exe.lower())
|
||||
if not e:
|
||||
fail(f"{exe} isn't running")
|
||||
pid = e.pid
|
||||
proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS
|
||||
if not proc:
|
||||
fail(f"couldn't open {exe}")
|
||||
path, base = inject(proc, pid, "UEVRPluginNullifier.dll")
|
||||
local = k32.LoadLibraryW(path)
|
||||
fn = local and k32.GetProcAddress(local, b"nullify")
|
||||
if not fn:
|
||||
fail("couldn't find UEVRPluginNullifier.dll's nullify")
|
||||
offset = fn - local # same DLL, same layout in both processes
|
||||
k32.FreeLibrary(local)
|
||||
# The frontend waits 2 s for nullify and carries on either way; so do we, but say so.
|
||||
nullified = remote_call(proc, base + offset, None, 2000)
|
||||
inject(proc, pid, "openvr_api.dll")
|
||||
# The frontend saves the runtime choice in the per-game config before UEVRBackend reads it.
|
||||
os.makedirs(profile, exist_ok=True)
|
||||
cfg = os.path.join(profile, "config.txt")
|
||||
lines = open(cfg).read().splitlines() if os.path.exists(cfg) else []
|
||||
lines = [l for l in lines if not l.startswith("Frontend_RequestedRuntime=")] + ["Frontend_RequestedRuntime=openvr_api.dll"]
|
||||
open(cfg, "w").write("\n".join(lines) + "\n")
|
||||
inject(proc, pid, "UEVRBackend.dll")
|
||||
k32.CloseHandle(proc)
|
||||
print(json.dumps({"pid": pid, "nullified": nullified}))
|
||||
"""
|
||||
|
||||
|
||||
class Fail(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def libraries():
|
||||
"""Steam library folders: the default one plus any in libraryfolders.vdf."""
|
||||
libs = [STEAM]
|
||||
try:
|
||||
text = (STEAM / "steamapps/libraryfolders.vdf").read_text(errors="replace")
|
||||
libs += [Path(p.replace("\\\\", "\\")) for p in re.findall(r'"path"\s+"([^"]+)"', text)]
|
||||
except OSError:
|
||||
pass
|
||||
seen, out = set(), []
|
||||
for lib in libs:
|
||||
key = os.path.realpath(lib)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(lib)
|
||||
return out
|
||||
|
||||
|
||||
def game(appid):
|
||||
"""Where the game is installed and its Proton prefix, from its app manifest."""
|
||||
for lib in libraries():
|
||||
manifest = lib / f"steamapps/appmanifest_{appid}.acf"
|
||||
try:
|
||||
text = manifest.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
m = re.search(r'"installdir"\s+"([^"]+)"', text)
|
||||
if not m:
|
||||
continue
|
||||
name = re.search(r'"name"\s+"([^"]*)"', text)
|
||||
return {"name": name.group(1) if name else str(appid),
|
||||
"dir": lib / "steamapps/common" / m.group(1),
|
||||
"prefix": lib / f"steamapps/compatdata/{appid}/pfx"}
|
||||
raise Fail(f"app {appid} isn't installed on this Frame")
|
||||
|
||||
|
||||
def shipping_exe(game_dir):
|
||||
"""The Unreal Engine game binary (…/Binaries/Win64/*-Win64-Shipping.exe), or None."""
|
||||
found = [p for p in game_dir.glob("*/Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
|
||||
found += [p for p in game_dir.glob("Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
|
||||
return max(found, key=lambda p: p.stat().st_size) if found else None
|
||||
|
||||
|
||||
def game_pid(exe_name):
|
||||
"""The running Wine process for this .exe (its cmdline is the Windows path)."""
|
||||
want = "\\" + exe_name.lower()
|
||||
for d in Path("/proc").iterdir():
|
||||
if not d.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
cmd = (d / "cmdline").read_bytes().split(b"\0")[0].decode(errors="replace").lower()
|
||||
except OSError:
|
||||
continue
|
||||
if cmd.endswith(want):
|
||||
return int(d.name)
|
||||
return None
|
||||
|
||||
|
||||
def loaded(pid, name):
|
||||
try:
|
||||
return any(line.rstrip().endswith("/" + name) for line in open(f"/proc/{pid}/maps"))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def receipt_path(appid):
|
||||
return RECEIPTS / f"{appid}-uevr.json"
|
||||
|
||||
|
||||
def read_receipt(appid):
|
||||
try:
|
||||
return json.loads(receipt_path(appid).read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def managed_dir(g):
|
||||
return g["prefix"] / "drive_c" / MANAGED
|
||||
|
||||
|
||||
def user_dirs(g, exe):
|
||||
"""What UEVR itself writes into the prefix when it first runs."""
|
||||
user = g["prefix"] / "drive_c/users/steamuser/AppData"
|
||||
return {"profile": user / "Roaming/UnrealVRMod" / exe.stem, # per-game settings and log.txt
|
||||
"injector": user / "Local/praydog"} # the injector's own settings
|
||||
|
||||
|
||||
def status(appid):
|
||||
g = game(appid)
|
||||
exe = shipping_exe(g["dir"])
|
||||
r = read_receipt(appid)
|
||||
pid = game_pid(exe.name) if exe else None
|
||||
return {"appid": appid, "name": g["name"], "mod": "UEVR", "version": UEVR["version"],
|
||||
"eligible": bool(exe), "exe": exe.name if exe else None,
|
||||
"prefix": g["prefix"].is_dir(), "installed": bool(r),
|
||||
"running": bool(pid), "injected": bool(pid and loaded(pid, "UEVRBackend.dll"))}
|
||||
|
||||
|
||||
def fetch(url, digest, algo):
|
||||
"""Download into the cache once, and check its hash every time it's used."""
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
path = CACHE / url.rsplit("/", 1)[1]
|
||||
if not path.exists():
|
||||
part = path.with_suffix(path.suffix + ".part")
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f:
|
||||
for block in iter(lambda: r.read(1 << 20), b""):
|
||||
if f.tell() + len(block) > MAX_DOWNLOAD:
|
||||
raise Fail(f"{url} is bigger than expected; stopped the download")
|
||||
f.write(block)
|
||||
except (OSError, Fail) as e:
|
||||
part.unlink(missing_ok=True)
|
||||
raise Fail(f"download failed: {url}: {e}")
|
||||
part.rename(path)
|
||||
h = hashlib.new(algo)
|
||||
with open(path, "rb") as f:
|
||||
for block in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(block)
|
||||
if h.hexdigest() != digest:
|
||||
path.unlink()
|
||||
raise Fail(f"{path.name} doesn't match its published {algo}; deleted it, try again")
|
||||
return path
|
||||
|
||||
|
||||
def unpack(archive, dest, budget):
|
||||
"""Extract a ZIP into dest, refusing anything that would land outside it."""
|
||||
root = os.path.realpath(dest)
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
for info in z.infolist():
|
||||
target = os.path.realpath(os.path.join(dest, info.filename))
|
||||
if target != root and not target.startswith(root + os.sep):
|
||||
raise Fail(f"{archive.name} has an entry outside its folder: {info.filename}")
|
||||
if (info.external_attr >> 16) & 0o170000 == 0o120000:
|
||||
raise Fail(f"{archive.name} contains a symlink: {info.filename}")
|
||||
budget -= info.file_size
|
||||
if budget < 0:
|
||||
raise Fail(f"{archive.name} unpacks to more than expected")
|
||||
z.extractall(dest)
|
||||
return budget
|
||||
|
||||
|
||||
def install(appid):
|
||||
g = game(appid)
|
||||
exe = shipping_exe(g["dir"])
|
||||
if not exe:
|
||||
raise Fail(f"{g['name']} doesn't look like an Unreal Engine game (no *-Win64-Shipping.exe), so UEVR can't hook it")
|
||||
if not g["prefix"].is_dir():
|
||||
raise Fail(f"{g['name']} has no Proton prefix yet; play it once, then add the mod")
|
||||
if game_pid(exe.name):
|
||||
raise Fail(f"{g['name']} is running; quit it first")
|
||||
if read_receipt(appid):
|
||||
return {"message": f"UEVR {UEVR['version']} is already installed for {g['name']}", **status(appid)}
|
||||
archives = [(fetch(UEVR["url"], UEVR["sha256"], "sha256"), UEVR["dir"])]
|
||||
archives.append((fetch(PYTHON["url"], PYTHON["sha256"], "sha256"), PYTHON["dir"]))
|
||||
base = managed_dir(g)
|
||||
dirs = user_dirs(g, exe)
|
||||
fresh = [str(d) for d in (dirs["profile"].parent, base) if not d.exists()] # remove on uninstall if empty
|
||||
stage = base / f".staging-{os.getpid()}"
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
try:
|
||||
budget = MAX_UNPACKED
|
||||
for archive, sub in archives:
|
||||
budget = unpack(archive, stage / sub, budget)
|
||||
for need in (stage / UEVR["dir"] / "UEVRBackend.dll", stage / PYTHON["dir"] / "python.exe"):
|
||||
if not need.is_file():
|
||||
raise Fail(f"the download has no {need.name}")
|
||||
if game_pid(exe.name): # it may have started during the download
|
||||
raise Fail(f"{g['name']} started; quit it first")
|
||||
(stage / PYTHON["dir"] / "frame_inject.py").write_text(INJECT_PY)
|
||||
for sub in (UEVR["dir"], PYTHON["dir"]):
|
||||
shutil.rmtree(base / sub, ignore_errors=True)
|
||||
(stage / sub).rename(base / sub)
|
||||
finally:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
if str(base) in fresh:
|
||||
try:
|
||||
base.rmdir() # only succeeds if the install failed and left it empty
|
||||
except OSError:
|
||||
pass
|
||||
receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name,
|
||||
"installed": int(time.time()), "prefix": str(g["prefix"]),
|
||||
"remove": [str(base / UEVR["dir"]), str(base / PYTHON["dir"])],
|
||||
# Parents that didn't exist yet (UEVR makes UnrealVRMod), deepest first.
|
||||
"remove_if_empty": fresh,
|
||||
# UEVR creates these on first injection. Remove them on uninstall
|
||||
# only if they weren't there before, so earlier settings survive.
|
||||
"remove_if_created": {k: str(v) for k, v in dirs.items() if not v.exists()},
|
||||
"sources": [UEVR["url"], PYTHON["url"]]}
|
||||
RECEIPTS.mkdir(parents=True, exist_ok=True)
|
||||
tmp = receipt_path(appid).with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(receipt, indent=1))
|
||||
tmp.rename(receipt_path(appid))
|
||||
return {"message": f"Installed UEVR {UEVR['version']} for {g['name']}", **status(appid)}
|
||||
|
||||
|
||||
def x11_windows(display):
|
||||
"""(id, name, width) for each named top-level window, via xwininfo."""
|
||||
try:
|
||||
out = subprocess.run(["xwininfo", "-root", "-tree"], env={**os.environ, "DISPLAY": display},
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
return [(int(m[1], 16), m[2], int(m[3])) for m in re.finditer(r'(0x[0-9a-f]+) "([^"]*)":.*?\)\s+(\d+)x\d+', out)]
|
||||
|
||||
|
||||
def frame_submits(appid):
|
||||
"""SteamVR's count of frames the app has submitted, or None if it isn't a scene app."""
|
||||
try:
|
||||
stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout)
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if not isinstance(stats, list):
|
||||
return None
|
||||
return next((s.get("frame_submits") for s in stats if isinstance(s, dict) and s.get("key") == f"steam.app.{appid}"), None)
|
||||
|
||||
|
||||
def wait(what, seconds, check, step=2):
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
v = check()
|
||||
if v is not None and v is not False: # 0 frames is still an answer
|
||||
return v
|
||||
time.sleep(step)
|
||||
raise Fail(f"timed out waiting for {what}")
|
||||
|
||||
|
||||
def start(appid):
|
||||
g = game(appid)
|
||||
r = read_receipt(appid)
|
||||
if not r:
|
||||
raise Fail(f"UEVR isn't installed for {g['name']}")
|
||||
exe = r["exe"]
|
||||
pid = game_pid(exe)
|
||||
if pid and loaded(pid, "UEVRBackend.dll"):
|
||||
return {"message": f"UEVR is already running in {g['name']}", "frame_submits": frame_submits(appid), **status(appid)}
|
||||
if not pid:
|
||||
subprocess.Popen(["steam", f"steam://rungameid/{appid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")}, start_new_session=True)
|
||||
pid = wait(f"{g['name']} to start", 120, lambda: game_pid(exe))
|
||||
try:
|
||||
environ = Path(f"/proc/{pid}/environ").read_text(errors="replace")
|
||||
except OSError:
|
||||
raise Fail(f"{exe} exited") from None
|
||||
game_env = dict(line.split("=", 1) for line in environ.split("\0") if GAME_ENV.match(line))
|
||||
display = game_env.get("DISPLAY")
|
||||
if not display or "WINEPREFIX" not in game_env:
|
||||
raise Fail(f"{exe} doesn't look like a Proton game process")
|
||||
# UEVR hooks the game's D3D11/12 device, so wait for its window. Proton's
|
||||
# own helper windows ("Steam", "Default IME", …) are small.
|
||||
wait("the game window", 90, lambda: any(w[2] >= 640 for w in x11_windows(display)))
|
||||
time.sleep(10)
|
||||
# Our own environment (HOME and the rest) under the game's Wine settings.
|
||||
env = {**os.environ, **game_env}
|
||||
base = f"C:\\{MANAGED}"
|
||||
profile = f"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\{Path(exe).stem}"
|
||||
try:
|
||||
out = subprocess.run(["wine", f"{base}\\{PYTHON['dir']}\\python.exe", f"{base}\\{PYTHON['dir']}\\frame_inject.py",
|
||||
exe, f"{base}\\{UEVR['dir']}", profile], env=env, capture_output=True, text=True,
|
||||
timeout=120, stdin=subprocess.DEVNULL,
|
||||
cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED))
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Fail("the injection step didn't finish in 2 minutes") from None
|
||||
reply = None
|
||||
for line in reversed(out.stdout.splitlines()):
|
||||
try:
|
||||
reply = json.loads(line)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(reply, dict):
|
||||
reply = None
|
||||
if not reply or "error" in reply:
|
||||
raise Fail("UEVR injection failed: " + (reply or {}).get("error", (out.stderr.strip().splitlines() or ["no output"])[-1]))
|
||||
try:
|
||||
wait("UEVRBackend.dll to load in the game", 20, lambda: loaded(pid, "UEVRBackend.dll"))
|
||||
except Fail:
|
||||
raise Fail(f"the injection reported success but {exe} has no UEVRBackend.dll") from None
|
||||
submits = None
|
||||
try:
|
||||
submits = wait("SteamVR frames", 30, lambda: frame_submits(appid))
|
||||
except Fail:
|
||||
pass
|
||||
return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits is not None
|
||||
else "; SteamVR hasn't reported frames from it yet"),
|
||||
"frame_submits": submits, **status(appid)}
|
||||
|
||||
|
||||
def uninstall(appid):
|
||||
r = read_receipt(appid)
|
||||
if not r:
|
||||
raise Fail(f"UEVR isn't installed for app {appid}")
|
||||
if game_pid(r["exe"]):
|
||||
raise Fail("the game is running; quit it first")
|
||||
prefix = os.path.realpath(r["prefix"])
|
||||
if not prefix.endswith(f"/steamapps/compatdata/{appid}/pfx"):
|
||||
raise Fail(f"receipt names a prefix that isn't app {appid}'s: {r['prefix']}")
|
||||
# Only ever our folder and UEVR's own settings folders for this game, even
|
||||
# if the receipt says otherwise.
|
||||
managed = os.path.join(prefix, "drive_c", MANAGED)
|
||||
uevr_dirs = {os.path.realpath(str(d)) for d in user_dirs({"prefix": Path(prefix)}, Path(r["exe"])).values()}
|
||||
targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values())
|
||||
for t in targets:
|
||||
real = os.path.realpath(t)
|
||||
if not (real.startswith(managed + os.sep) or real in uevr_dirs):
|
||||
raise Fail(f"receipt lists a path Frame Control didn't create: {t}")
|
||||
for t in targets:
|
||||
shutil.rmtree(t, ignore_errors=True)
|
||||
left = [t for t in targets if os.path.lexists(t)]
|
||||
if left: # keep the receipt, so Remove can be tried again
|
||||
raise Fail("couldn't remove " + ", ".join(left))
|
||||
parents = {os.path.realpath(os.path.join(prefix, "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod")), managed}
|
||||
for t in r.get("remove_if_empty", []):
|
||||
if os.path.realpath(t) in parents:
|
||||
try:
|
||||
os.rmdir(t)
|
||||
except OSError:
|
||||
pass # something else lives there now
|
||||
receipt_path(appid).unlink()
|
||||
if not any(RECEIPTS.glob("*-uevr.json")): # no other game uses the downloads
|
||||
shutil.rmtree(CACHE, ignore_errors=True)
|
||||
try:
|
||||
RECEIPTS.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return {"message": "Removed UEVR", "appid": appid, "removed": targets}
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2 or argv[0] not in ("status", "install", "start", "uninstall") or not argv[1].isdigit():
|
||||
raise Fail("usage: status|install|start|uninstall APPID")
|
||||
action = {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]]
|
||||
if action is status:
|
||||
return status(int(argv[1]))
|
||||
import fcntl # only on the Frame; the server and its tests also import this module on Windows
|
||||
# One change at a time, or an uninstall could delete what an install is moving in.
|
||||
LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOCK, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
raise Fail("another mod action is still running on the Frame; try again when it finishes") from None
|
||||
return action(int(argv[1]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(json.dumps(main(sys.argv[1:])))
|
||||
except Fail as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
except Exception as e: # still one JSON object, for the server to show
|
||||
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
|
||||
sys.exit(1)
|
||||
+114
-8
@@ -346,9 +346,9 @@
|
||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||
.rep-item .s { white-space: normal; }
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg { width: min(640px, calc(100vw - 40px)); }
|
||||
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
|
||||
@@ -357,7 +357,7 @@
|
||||
#bugForm .popt { margin: 14px 0 0; }
|
||||
#bugForm .sentlog { max-height: 200px; }
|
||||
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2, #modDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
.about-text { max-height: 55vh; overflow: auto; }
|
||||
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
|
||||
.about-text summary { cursor: pointer; margin: 8px 0; }
|
||||
@@ -626,6 +626,8 @@
|
||||
.source-repo-form input { margin-top:7px; display:block; border-radius:6px; }
|
||||
.source-repo-form details { font-size:11px; color:var(--muted); }
|
||||
.source-repo-form button { justify-self:start; border-radius:6px; }
|
||||
.source-token { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin:8px 0 4px; }
|
||||
.source-token input { flex:1; min-width:150px; border-radius:6px; font-size:12px; }
|
||||
#appStore :focus-visible, .store-dialog :focus-visible, .store-tabs :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
|
||||
.store-search input:focus-visible { outline:0!important; }
|
||||
@media(min-width:1400px) { .store-grid { grid-template-columns:repeat(5,minmax(0,1fr)); } }
|
||||
@@ -1318,7 +1320,7 @@
|
||||
<button class="store-close" data-close="sourcesDialog" aria-label="Close source settings">×</button>
|
||||
<div class="sources-intro"><div class="store-eyebrow">Your app shelves</div><h1 id="sourcesTitle">A world of apps.</h1><p>Choose where you discover games and apps. We bring them together for you.</p></div>
|
||||
<div id="sourcesList"></div>
|
||||
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><button class="action">Add source</button></form>
|
||||
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><details><summary>This repository needs a login (optional)</summary><label>Username<input name="username" type="text" autocomplete="off" autocapitalize="none" spellcheck="false"></label><label>Password<input name="password" type="password" autocomplete="new-password"></label><p>Kept on this computer, apart from the address, and only sent to this repository over HTTPS.</p></details><button class="action">Add source</button></form>
|
||||
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
|
||||
</dialog>
|
||||
|
||||
@@ -1332,6 +1334,21 @@
|
||||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="modDlg" aria-labelledby="modTitle">
|
||||
<h2 id="modTitle">Play in VR with UEVR</h2>
|
||||
<p id="modState" class="sub"></p>
|
||||
<p class="sub">UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads
|
||||
the official UEVR 1.05 release and python.org's Windows Python, which it uses to load UEVR into the game
|
||||
(about 20 MB), checks their hashes, and keeps them in this game's Proton prefix, not the game's folder.
|
||||
Remove takes them out again.</p>
|
||||
<p class="sub">Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies;
|
||||
UEVR's own menu in the headset has the per-game settings.</p>
|
||||
<div class="row rep-actions"><span class="sub" id="modMsg"></span><span class="spacer"></span>
|
||||
<button type="button" class="small" id="modClose">Close</button>
|
||||
<button type="button" class="small" id="modRemove" hidden>Remove UEVR</button>
|
||||
<button type="button" class="action small" id="modInstall" hidden>Install UEVR</button>
|
||||
<button type="button" class="play small" id="modStart" hidden>▶ PLAY IN VR</button></div>
|
||||
</dialog>
|
||||
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
|
||||
<h2 id="aboutTitle">About and licences</h2>
|
||||
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
|
||||
@@ -3217,7 +3234,8 @@ function gameButtons(id, name, free) {
|
||||
const open = `<button class="small" data-gm="store" data-id="${id}" data-name="${esc(name)}" title="Open the store page in the headset">Store on Frame</button>`;
|
||||
if (d && d.appid === id) return `<span class="tag">${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%</span>`;
|
||||
if (g && g.installed) return `<span class="tag">Installed</span>` +
|
||||
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>`;
|
||||
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>` +
|
||||
`<button class="small" data-mod="${id}" data-name="${esc(name)}" title="Flat-to-VR mod for Unreal Engine games">VR mod</button>`;
|
||||
if (g) return `<button class="action small" data-gm="install" data-id="${id}" data-name="${esc(name)}">Install on Frame</button>`;
|
||||
return `<a href="${storeUrl}" target="_blank" rel="noopener" title="Opens in your browser">${free ? "Get" : "Buy"} on Steam ↗</a>${open}`;
|
||||
}
|
||||
@@ -3289,6 +3307,45 @@ document.body.addEventListener("click", async e => {
|
||||
});
|
||||
loadOwned();
|
||||
|
||||
// ---- VR mod (UEVR) for one installed game; frame_mods.py does the work ----
|
||||
const mod = { appid: null, name: "" };
|
||||
function showMod(st, note) {
|
||||
const busy = note === null;
|
||||
$("modTitle").textContent = `${mod.name} in VR`;
|
||||
$("modState").textContent = !st ? "Checking the game on the Frame…"
|
||||
: !st.eligible ? "This doesn't look like an Unreal Engine game, so UEVR can't run it."
|
||||
: !st.prefix ? "Play the game once first, so Steam sets up its Proton prefix."
|
||||
: st.injected ? "UEVR is running in the game now. Put on the headset."
|
||||
: st.installed ? `UEVR ${st.version} is installed for this game.${st.running ? " The game is running; Play in VR adds UEVR to it." : ""}`
|
||||
: `UEVR ${st.version} isn't installed for this game yet.`;
|
||||
$("modMsg").textContent = busy ? "Working… this can take a few minutes" : note || "";
|
||||
const ok = st && st.eligible && st.prefix;
|
||||
$("modInstall").hidden = !ok || st.installed; $("modStart").hidden = !ok || !st.installed || st.injected;
|
||||
$("modRemove").hidden = !st || !st.installed;
|
||||
for (const b of ["modInstall", "modStart", "modRemove"]) $(b).disabled = busy || (b === "modRemove" && st && st.running);
|
||||
}
|
||||
async function modAction(action, label) {
|
||||
const appid = mod.appid;
|
||||
showMod(mod.st, null);
|
||||
const res = await act(`${label} for ${mod.name}`, () => api("/api/mods", { action, appid }));
|
||||
if (appid !== mod.appid) return;
|
||||
try { mod.st = await api("/api/mods", { action: "status", appid }); } catch {}
|
||||
showMod(mod.st, res ? res.message : "Didn't work; the log has the reason");
|
||||
}
|
||||
document.body.addEventListener("click", async e => {
|
||||
const b = e.target.closest("[data-mod]"); if (!b) return;
|
||||
mod.appid = b.dataset.mod; mod.name = b.dataset.name; mod.st = null;
|
||||
showMod(null, "");
|
||||
$("modDlg").showModal();
|
||||
const appid = mod.appid;
|
||||
try { const st = await api("/api/mods", { action: "status", appid }); if (appid === mod.appid) { mod.st = st; showMod(st, ""); } }
|
||||
catch (err) { if (appid === mod.appid) $("modState").textContent = err.message; }
|
||||
});
|
||||
$("modInstall").onclick = () => modAction("install", "Install UEVR");
|
||||
$("modStart").onclick = () => modAction("start", "Start UEVR");
|
||||
$("modRemove").onclick = () => modAction("uninstall", "Remove UEVR");
|
||||
$("modClose").onclick = () => $("modDlg").close();
|
||||
|
||||
// ---- Discover apps ----
|
||||
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
|
||||
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
|
||||
@@ -3306,12 +3363,18 @@ function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue
|
||||
document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true);
|
||||
function sourceMark(s) { const name=storeName(s); return `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
|
||||
function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); }
|
||||
// GitHub's optional access token: only whether one is set is ever shown, never its value.
|
||||
function sourceToken(s) {
|
||||
const t=s.token;if(!t)return "";
|
||||
const note=t.rejected ? "GitHub rejected this token, so searches continue without it. Replace or clear it." : t.from==="env" ? "Using the token from FRAME_GITHUB_TOKEN." : t.set ? "A token is saved." : "Optional: a token raises GitHub's limit of 60 requests an hour.";
|
||||
return `<form class="source-token" data-token="${esc(s.id)}"><input name="token" type="password" autocomplete="off" spellcheck="false" aria-label="${esc(storeName(s.name))} access token" placeholder="${t.set ? "Replace access token" : "Access token"}"><button class="small">Save</button>${t.from==="saved" ? `<button type="button" class="small" data-clear-token="${esc(s.id)}">Clear</button>` : ""}</form><small>${esc(note)}</small>`;
|
||||
}
|
||||
async function loadSources() {
|
||||
try {
|
||||
const {sources} = await api("/api/sources"); sourceState.sources = sources;
|
||||
$("sourceFilter").innerHTML = `<option value="">All sources</option>` + sources.filter(s=>s.enabled).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
|
||||
$("sourceFilter").value = sourceState.source;
|
||||
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</small>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
|
||||
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}${s.login ? " · Uses a login" : ""}</small>${sourceToken(s)}${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
|
||||
} catch (e) { $("sourceError").textContent = e.message; }
|
||||
}
|
||||
function storeCard(app) {
|
||||
@@ -3439,7 +3502,8 @@ $("detailBody").onclick=e=>{if(e.target.closest('[data-close]'))return $("appDet
|
||||
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}};
|
||||
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
|
||||
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
|
||||
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
|
||||
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});const c=e.target.closest('[data-clear-token]');if(c)changeSource({action:'clear-token',source:c.dataset.clearToken});};
|
||||
$("sourcesList").onsubmit=e=>{const f=e.target.closest('[data-token]');if(!f)return;e.preventDefault();const token=f.elements.token.value.trim();f.reset();if(token)changeSource({action:'set-token',source:f.dataset.token,token});};
|
||||
async function addSource(body) {
|
||||
$("sourceError").textContent="Checking the repository. Large ones can take a minute…";
|
||||
let error="";
|
||||
@@ -3447,7 +3511,49 @@ async function addSource(body) {
|
||||
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
|
||||
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
|
||||
}
|
||||
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
|
||||
// A login typed into the address moves into the login fields, on the page too, before anything
|
||||
// is sent: the address is shown and logged, the login never. It stays there if adding fails.
|
||||
// Plain text, not the URL parser: an address it rejects (or a login it can't decode) must
|
||||
// still lose its login first. Decoding falls back to the text as typed.
|
||||
// Percent-decode what decodes; keep any byte sequence that isn't UTF-8 as typed.
|
||||
// One forward pass: each lead byte gives its sequence length (at most 4 bytes).
|
||||
function decodeLenient(s) {
|
||||
return s.replace(/(?:%[0-9a-f]{2})+/gi, run => {
|
||||
const b=run.match(/%[0-9a-f]{2}/gi); let out='', i=0;
|
||||
while(i<b.length){
|
||||
const v=parseInt(b[i].slice(1),16);
|
||||
const n=v<0x80 ? 1 : v>=0xC2&&v<0xE0 ? 2 : v>=0xE0&&v<0xF0 ? 3 : v>=0xF0&&v<0xF5 ? 4 : 0;
|
||||
if(n){ try { out+=decodeURIComponent(b.slice(i,i+n).join('')); i+=n; continue; } catch {} }
|
||||
out+=b[i]; i++;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
// Moves a login typed into the address into the login fields. Returns false for an
|
||||
// address the URL parser rejects that may hold a login: it is refused, not sent.
|
||||
function moveLogin(form) {
|
||||
// Normalise the way URL parsers do: trim C0 controls and spaces, drop tabs and newlines.
|
||||
const el=form.elements;
|
||||
el.url.value=el.url.value.replace(/^[\u0000- ]+|[\u0000- ]+$/g,'').replace(/[\t\n\r]/g,'');
|
||||
let u;
|
||||
// A backslash and an '@' together are read differently by browsers and by the
|
||||
// server, so the address can't be split safely: refuse it rather than guess.
|
||||
if(el.url.value.includes('@') && el.url.value.includes('\\'))return false;
|
||||
try { u=new URL(el.url.value); } catch { return !el.url.value.includes('@'); }
|
||||
if(!u.username && !u.password)return true;
|
||||
const user=decodeLenient(u.username), pass=decodeLenient(u.password);
|
||||
u.username=''; u.password=''; el.url.value=u.href;
|
||||
if(!el.username.value)el.username.value=user;
|
||||
if(!el.password.value)el.password.value=pass;
|
||||
el.username.closest('details').open=true;
|
||||
return true;
|
||||
}
|
||||
$("addSource").onsubmit=e=>{
|
||||
e.preventDefault();
|
||||
if(!moveLogin(e.target)){ $("sourceError").textContent="That address isn't valid. Put the username and password in the login fields below, not in the address."; return; }
|
||||
const f=new FormData(e.target), url=f.get('url'), username=f.get('username'), password=f.get('password');
|
||||
addSource({action:'add',url,fingerprint:f.get('fingerprint'),...(username ? {username,password} : {})});
|
||||
};
|
||||
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
|
||||
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
|
||||
function showAndroidTab(tab) {
|
||||
|
||||
+46
-9
@@ -709,13 +709,14 @@ def launch(body):
|
||||
return {"message": f"Launching {appid}"}
|
||||
|
||||
|
||||
def steam_frame(*args, timeout=40):
|
||||
"""Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON."""
|
||||
def steam_frame(*args, timeout=40, script="frame_steam.py"):
|
||||
"""Run frame_steam.py (it drives the Steam client), or another on-Frame
|
||||
helper with the same JSON contract, on the Frame and return its JSON."""
|
||||
try:
|
||||
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
|
||||
stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout)
|
||||
stdin=(HERE / script).read_text(), timeout=timeout)
|
||||
except Failure as e:
|
||||
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh()
|
||||
# The helper prints {"error": ...} on stdout when it fails, but ssh()
|
||||
# reports stderr instead if there was any, so look in both.
|
||||
for line in [*reversed(getattr(e, "stdout", "").splitlines()), *reversed(str(e).splitlines())]:
|
||||
try:
|
||||
@@ -766,6 +767,20 @@ def vr(body):
|
||||
return result
|
||||
|
||||
|
||||
# Downloads are about 20 MB; starting waits for the game, the injection and SteamVR.
|
||||
MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60}
|
||||
|
||||
|
||||
def mods(body):
|
||||
"""UEVR for one installed Unreal game: status, install, start (inject) or uninstall."""
|
||||
appid, action = str(body.get("appid", "")), body.get("action")
|
||||
if not APPID.match(appid):
|
||||
raise Failure("bad appid", 400)
|
||||
if action not in MOD_TIMEOUT:
|
||||
raise Failure("action must be " + ", ".join(MOD_TIMEOUT), 400)
|
||||
return steam_frame(action, appid, timeout=MOD_TIMEOUT[action], script="frame_mods.py")
|
||||
|
||||
|
||||
def steam_search(query):
|
||||
q = parse_qs(query)
|
||||
cc = (q.get("cc") or [""])[0].upper()
|
||||
@@ -2399,6 +2414,16 @@ def source_text(body, key, optional=False):
|
||||
return value.strip()
|
||||
|
||||
|
||||
def login_text(body, key):
|
||||
"""A username, password or token: kept exactly (no stripping) and never echoed back."""
|
||||
value = body.get(key)
|
||||
if value in (None, ''):
|
||||
return None
|
||||
if not isinstance(value, str) or len(value) > 4096:
|
||||
raise Failure('Provide a valid ' + key, 400)
|
||||
return value
|
||||
|
||||
|
||||
def source_search(query):
|
||||
args = parse_qs(query)
|
||||
q = args.get('q', [''])[0]
|
||||
@@ -2437,19 +2462,31 @@ def source_manage(body):
|
||||
if action == 'add':
|
||||
url = source_text(body, 'url')
|
||||
fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True)
|
||||
parts = urlparse(url)
|
||||
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username:
|
||||
raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400)
|
||||
username, password = login_text(body, 'username'), login_text(body, 'password')
|
||||
try:
|
||||
parts = urlparse(url)
|
||||
parts.port
|
||||
except ValueError:
|
||||
raise Failure('Use an HTTPS or fdroidrepos:// repository URL', 400) from None
|
||||
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname:
|
||||
raise Failure('Use an HTTPS or fdroidrepos:// repository URL', 400)
|
||||
apk_search.repo_module() # fail now if this build can't manage repositories
|
||||
|
||||
def add(): # downloads and verifies the whole index: a job, not a request
|
||||
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name)
|
||||
# A login (here or in the URL) is stored apart and never comes back in a response.
|
||||
login = {'username': username, 'password': password} if username or password else {}
|
||||
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name, **login)
|
||||
message = 'Added ' + source['name']
|
||||
if source.get('trust_on_first_use'):
|
||||
message += '. Trusted on first use: ' + source['fingerprint'].upper()
|
||||
return {'message': message, 'source': {k: source.get(k) for k in
|
||||
('id', 'name', 'fingerprint', 'trust_on_first_use')}}
|
||||
return start_job('Add repository', add)
|
||||
if action in ('set-token', 'clear-token'):
|
||||
token = login_text(body, 'token') if action == 'set-token' else None
|
||||
if action == 'set-token' and not token:
|
||||
raise Failure('Paste a token', 400)
|
||||
return apk_search.set_token(source_text(body, 'source'), token)
|
||||
if action == 'game-data':
|
||||
package = source_text(body, 'package')
|
||||
return start_job('Add game data', lambda: apk_search.add_game_data(package))
|
||||
@@ -2467,7 +2504,7 @@ POST = {
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
|
||||
Reference in new issue
Block a user